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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/aws-cloud-formation.pngbin2545 -> 0 bytes
-rw-r--r--app/assets/images/vulnerability/kontra-logo.svg1
-rw-r--r--app/assets/images/vulnerability/scw-logo.svg1
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue43
-rw-r--r--app/assets/javascripts/access_tokens/components/tokens_app.vue1
-rw-r--r--app/assets/javascripts/access_tokens/index.js2
-rw-r--r--app/assets/javascripts/activities.js2
-rw-r--r--app/assets/javascripts/admin/applications/components/delete_application.vue84
-rw-r--r--app/assets/javascripts/admin/applications/index.js15
-rw-r--r--app/assets/javascripts/admin/topics/components/remove_avatar.vue67
-rw-r--r--app/assets/javascripts/admin/topics/index.js22
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue7
-rw-r--r--app/assets/javascripts/admin/users/components/user_avatar.vue2
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue7
-rw-r--r--app/assets/javascripts/api.js8
-rw-r--r--app/assets/javascripts/attention_requests/components/navigation_popover.vue120
-rw-r--r--app/assets/javascripts/attention_requests/index.js73
-rw-r--r--app/assets/javascripts/authentication/webauthn/util.js11
-rw-r--r--app/assets/javascripts/behaviors/markdown/editor_extensions.js78
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/bold.js22
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/code.js17
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_diff.js66
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_html.js71
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/italic.js16
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/link.js58
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/math.js61
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/strike.js31
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/audio.js9
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/blockquote.js18
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js16
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/code_block.js127
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/description_details.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/description_list.js29
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/description_term.js32
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/details.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/doc.js21
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/emoji.js84
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/hard_break.js18
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/heading.js26
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js15
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/image.js83
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/list_item.js17
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js29
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js38
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/paragraph.js29
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/playable.js93
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/reference.js85
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/summary.js32
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table.js32
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_body.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_cell.js54
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_head.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js40
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js49
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_row.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list.js39
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js70
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/text.js23
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/video.js10
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/schema.js32
-rw-r--r--app/assets/javascripts/behaviors/markdown/serializer.js32
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js16
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js7
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue5
-rw-r--r--app/assets/javascripts/blob/components/blob_header_default_actions.vue3
-rw-r--r--app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue7
-rw-r--r--app/assets/javascripts/blob/csv/csv_viewer.vue32
-rw-r--r--app/assets/javascripts/blob/template_selector.js23
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js11
-rw-r--r--app/assets/javascripts/boards/boards_util.js14
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue40
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue14
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue7
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js81
-rw-r--r--app/assets/javascripts/boards/graphql.js1
-rw-r--r--app/assets/javascripts/boards/index.js42
-rw-r--r--app/assets/javascripts/boards/mount_filtered_search_issue_boards.js10
-rw-r--r--app/assets/javascripts/boards/stores/actions.js36
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js17
-rw-r--r--app/assets/javascripts/boards/stores/state.js1
-rw-r--r--app/assets/javascripts/branches/ajax_loading_spinner.js31
-rw-r--r--app/assets/javascripts/captcha/apollo_captcha_link.js2
-rw-r--r--app/assets/javascripts/captcha/captcha_modal.vue6
-rw-r--r--app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js4
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint.vue2
-rw-r--r--app/assets/javascripts/ci_secure_files/components/secure_files_list.vue133
-rw-r--r--app/assets/javascripts/ci_secure_files/index.js17
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue16
-rw-r--r--app/assets/javascripts/ci_variable_list/ci_variable_list.js3
-rw-r--r--app/assets/javascripts/clusters/agents/components/create_token_button.vue246
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/token_table.vue41
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js7
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/cache_update.js24
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql11
-rw-r--r--app/assets/javascripts/clusters/agents/index.js5
-rw-r--r--app/assets/javascripts/clusters/components/new_cluster.vue6
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue27
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_token.vue109
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue3
-rw-r--r--app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue50
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue8
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_actions.vue60
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_empty_state.vue4
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_main_view.vue36
-rw-r--r--app/assets/javascripts/clusters_list/components/delete_agent_button.vue2
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue175
-rw-r--r--app/assets/javascripts/clusters_list/constants.js145
-rw-r--r--app/assets/javascripts/clusters_list/graphql/cache_update.js10
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql8
-rw-r--r--app/assets/javascripts/clusters_list/index.js58
-rw-r--r--app/assets/javascripts/clusters_list/load_clusters.js25
-rw-r--r--app/assets/javascripts/clusters_list/load_main_view.js57
-rw-r--r--app/assets/javascripts/code_navigation/components/app.vue27
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/components/step.vue150
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/constants.js67
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/index.js14
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/utils.js39
-rw-r--r--app/assets/javascripts/commons/nav/user_merge_requests.js23
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue29
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_provider.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue34
-rw-r--r--app/assets/javascripts/content_editor/components/loading_indicator.vue39
-rw-r--r--app/assets/javascripts/content_editor/constants.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/attachment.js17
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js86
-rw-r--r--app/assets/javascripts/content_editor/extensions/table.js3
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js48
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js13
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_deserializer.js33
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js27
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js6
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js9
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js19
-rw-r--r--app/assets/javascripts/contributors/stores/getters.js5
-rw-r--r--app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue33
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time.vue (renamed from app/assets/javascripts/cycle_analytics/components/total_time_component.vue)0
-rw-r--r--app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue61
-rw-r--r--app/assets/javascripts/deploy_tokens/components/revoke_button.vue6
-rw-r--r--app/assets/javascripts/deploy_tokens/init_revoke_button.js3
-rw-r--r--app/assets/javascripts/deprecated_notes.js33
-rw-r--r--app/assets/javascripts/diff.js42
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue18
-rw-r--r--app/assets/javascripts/diffs/components/hidden_files_warning.vue52
-rw-r--r--app/assets/javascripts/diffs/store/getters_versions_dropdowns.js13
-rw-r--r--app/assets/javascripts/dirty_submit/dirty_submit_form.js12
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js2
-rw-r--r--app/assets/javascripts/editor/schema/ci.json95
-rw-r--r--app/assets/javascripts/emoji/components/picker.vue1
-rw-r--r--app/assets/javascripts/environments/components/commit.vue1
-rw-r--r--app/assets/javascripts/environments/components/delete_environment_modal.vue3
-rw-r--r--app/assets/javascripts/environments/components/deployment.vue8
-rw-r--r--app/assets/javascripts/environments/components/enable_review_app_modal.vue10
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue10
-rw-r--r--app/assets/javascripts/environments/components/environment_folder.vue (renamed from app/assets/javascripts/environments/components/new_environment_folder.vue)19
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue390
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue21
-rw-r--r--app/assets/javascripts/environments/components/new_environments_app.vue252
-rw-r--r--app/assets/javascripts/environments/constants.js10
-rw-r--r--app/assets/javascripts/environments/graphql/client.js49
-rw-r--r--app/assets/javascripts/environments/graphql/queries/folder.query.graphql4
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js17
-rw-r--r--app/assets/javascripts/environments/index.js55
-rw-r--r--app/assets/javascripts/environments/new_index.js38
-rw-r--r--app/assets/javascripts/error_tracking/components/constants.js21
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue44
-rw-r--r--app/assets/javascripts/error_tracking/constants.js30
-rw-r--r--app/assets/javascripts/error_tracking/list.js8
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue64
-rw-r--r--app/assets/javascripts/error_tracking_settings/constants.js7
-rw-r--r--app/assets/javascripts/experimentation/components/gitlab_experiment.vue2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js3
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js14
-rw-r--r--app/assets/javascripts/google_cloud/components/app.vue4
-rw-r--r--app/assets/javascripts/google_cloud/components/gcp_regions_form.vue62
-rw-r--r--app/assets/javascripts/google_cloud/components/gcp_regions_list.vue56
-rw-r--r--app/assets/javascripts/google_cloud/components/home.vue25
-rw-r--r--app/assets/javascripts/google_cloud/components/revoke_oauth.vue38
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_form.vue47
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_list.vue18
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js44
-rw-r--r--app/assets/javascripts/gpg_badges.js3
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js2
-rw-r--r--app/assets/javascripts/graphql_shared/possibleTypes.json2
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql24
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue2
-rw-r--r--app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue18
-rw-r--r--app/assets/javascripts/header_search/components/header_search_default_items.vue4
-rw-r--r--app/assets/javascripts/header_search/index.js4
-rw-r--r--app/assets/javascripts/header_search/store/actions.js3
-rw-r--r--app/assets/javascripts/header_search/store/getters.js34
-rw-r--r--app/assets/javascripts/header_search/store/index.js3
-rw-r--r--app/assets/javascripts/header_search/store/mutations.js4
-rw-r--r--app/assets/javascripts/header_search/store/state.js12
-rw-r--r--app/assets/javascripts/ide/components/file_templates/bar.vue91
-rw-r--r--app/assets/javascripts/ide/components/file_templates/dropdown.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue24
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue12
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue2
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue57
-rw-r--r--app/assets/javascripts/incidents/constants.js7
-rw-r--r--app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql1
-rw-r--r--app/assets/javascripts/incidents/list.js1
-rw-r--r--app/assets/javascripts/integrations/constants.js13
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_checkbox.vue8
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue93
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue18
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue15
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/connection.vue45
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue33
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue32
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue6
-rw-r--r--app/assets/javascripts/integrations/edit/index.js14
-rw-r--r--app/assets/javascripts/integrations/edit/store/getters.js5
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_trigger.vue7
-rw-r--r--app/assets/javascripts/invite_members/components/invite_groups_modal.vue28
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue34
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue235
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_form.js7
-rw-r--r--app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js12
-rw-r--r--app/assets/javascripts/issuable/components/issuable_by_email.vue1
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js12
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue20
-rw-r--r--app/assets/javascripts/issues/list/constants.js11
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues.query.graphql3
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql7
-rw-r--r--app/assets/javascripts/issues/list/queries/search_users.query.graphql4
-rw-r--r--app/assets/javascripts/issues/list/utils.js11
-rw-r--r--app/assets/javascripts/issues/show/components/delete_issue_modal.vue5
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue41
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description_template.vue5
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue16
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue8
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue4
-rw-r--r--app/assets/javascripts/issues/show/index.js4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue26
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue (renamed from app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue)4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue124
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue23
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/constants.js21
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/index.js10
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue39
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pkce.js60
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue17
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue20
-rw-r--r--app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue22
-rw-r--r--app/assets/javascripts/jobs/components/log/line_header.vue2
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue38
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue129
-rw-r--r--app/assets/javascripts/jobs/components/table/constants.js10
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/cache_config.js30
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql15
-rw-r--r--app/assets/javascripts/jobs/components/table/index.js8
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue67
-rw-r--r--app/assets/javascripts/jobs/index.js2
-rw-r--r--app/assets/javascripts/lib/utils/accessor.js8
-rw-r--r--app/assets/javascripts/lib/utils/array_utility.js10
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js13
-rw-r--r--app/assets/javascripts/lib/utils/ignore_while_pending.js26
-rw-r--r--app/assets/javascripts/lib/utils/rails_ujs.js5
-rw-r--r--app/assets/javascripts/lib/utils/resize_observer.js22
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js54
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js14
-rw-r--r--app/assets/javascripts/loading_icon_for_legacy_js.js53
-rw-r--r--app/assets/javascripts/main.js18
-rw-r--r--app/assets/javascripts/member_expiration_date.js54
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue3
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue3
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue41
-rw-r--r--app/assets/javascripts/members/constants.js37
-rw-r--r--app/assets/javascripts/members/index.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue7
-rw-r--r--app/assets/javascripts/merge_request_tabs.js247
-rw-r--r--app/assets/javascripts/monitoring/components/charts/bar.vue34
-rw-r--r--app/assets/javascripts/monitoring/components/charts/column.vue34
-rw-r--r--app/assets/javascripts/monitoring/components/charts/gauge.vue36
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue34
-rw-r--r--app/assets/javascripts/monitoring/components/charts/stacked_column.vue46
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue103
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue13
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue5
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue8
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue14
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue14
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js6
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql (renamed from app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql)3
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue13
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue66
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js1
-rw-r--r--app/assets/javascripts/pages/admin/applications/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/clusters/connect/index.js (renamed from app/assets/javascripts/pages/admin/clusters/new/index.js)0
-rw-r--r--app/assets/javascripts/pages/admin/topics/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js8
-rw-r--r--app/assets/javascripts/pages/groups/clusters/connect/index.js (renamed from app/assets/javascripts/pages/groups/clusters/new/index.js)0
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js15
-rw-r--r--app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js28
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/ci/secure_files/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/clusters/connect/index.js (renamed from app/assets/javascripts/pages/projects/clusters/new/index.js)0
-rw-r--r--app/assets/javascripts/pages/projects/environments/index/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/app.vue43
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue25
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue93
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue148
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js79
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue7
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue10
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue4
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/pages_domains/form.js15
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js54
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js53
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js13
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue3
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js11
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue21
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue1
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue38
-rw-r--r--app/assets/javascripts/performance_bar/constants.js14
-rw-r--r--app/assets/javascripts/performance_bar/index.js41
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js2
-rw-r--r--app/assets/javascripts/persistent_user_callout.js19
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue18
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue6
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue22
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue3
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue8
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue7
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js45
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js6
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue25
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue1
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/commit.vue4
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/input.vue99
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/step.vue149
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/widgets/list.vue195
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/wrapper.vue185
-rw-r--r--app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue65
-rw-r--r--app/assets/javascripts/pipeline_wizard/validators.js4
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue100
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue170
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue267
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue16
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue226
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue85
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue30
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue128
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue7
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js4
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js6
-rw-r--r--app/assets/javascripts/profile/profile.js4
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js9
-rw-r--r--app/assets/javascripts/projects/project_new.js13
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js24
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js66
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue27
-rw-r--r--app/assets/javascripts/ref/stores/actions.js3
-rw-r--r--app/assets/javascripts/ref/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ref/stores/mutations.js5
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue13
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue7
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue12
-rw-r--r--app/assets/javascripts/related_issues/index.js1
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue2
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue12
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js14
-rw-r--r--app/assets/javascripts/reports/codequality_report/constants.js14
-rw-r--r--app/assets/javascripts/reports/constants.js1
-rw-r--r--app/assets/javascripts/repository/components/blob_button_group.vue14
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue77
-rw-r--r--app/assets/javascripts/repository/components/blob_edit.vue78
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue20
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue26
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue13
-rw-r--r--app/assets/javascripts/repository/components/delete_blob_modal.vue2
-rw-r--r--app/assets/javascripts/repository/constants.js10
-rw-r--r--app/assets/javascripts/repository/index.js4
-rw-r--r--app/assets/javascripts/repository/queries/application_info.query.graphql3
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql4
-rw-r--r--app/assets/javascripts/repository/queries/user_info.query.graphql8
-rw-r--r--app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue4
-rw-r--r--app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue4
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue22
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue118
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue9
-rw-r--r--app/assets/javascripts/runner/components/runner_delete_button.vue144
-rw-r--r--app/assets/javascripts/runner/components/runner_edit_button.vue4
-rw-r--r--app/assets/javascripts/runner/components/runner_jobs.vue11
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue33
-rw-r--r--app/assets/javascripts/runner/components/runner_pause_button.vue20
-rw-r--r--app/assets/javascripts/runner/components/runner_paused_badge.vue12
-rw-r--r--app/assets/javascripts/runner/components/runner_projects.vue12
-rw-r--r--app/assets/javascripts/runner/components/runner_update_form.vue9
-rw-r--r--app/assets/javascripts/runner/constants.js16
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_runner.query.graphql)3
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/runner_details.fragment.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql)3
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/runner_update.mutation.graphql)2
-rw-r--r--app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_runners.query.graphql)4
-rw-r--r--app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_runners_count.query.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runners.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_group_runners.query.graphql)6
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/runner_node.fragment.graphql)2
-rw-r--r--app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql)0
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue57
-rw-r--r--app/assets/javascripts/search/topbar/components/app.vue55
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js34
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue5
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue139
-rw-r--r--app/assets/javascripts/security_configuration/constants.js6
-rw-r--r--app/assets/javascripts/security_configuration/graphql/cache_utils.js40
-rw-r--r--app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue28
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/attention_requested_toggle.vue26
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/constants.js25
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/escalation_status.vue61
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue135
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/utils.js5
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/severity/severity.vue8
-rw-r--r--app/assets/javascripts/sidebar/constants.js19
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js57
-rw-r--r--app/assets/javascripts/sidebar/queries/escalation_status.query.graphql9
-rw-r--r--app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql10
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js12
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js2
-rw-r--r--app/assets/javascripts/single_file_diff.js3
-rw-r--r--app/assets/javascripts/terraform/components/empty_state.vue2
-rw-r--r--app/assets/javascripts/toggle_buttons.js63
-rw-r--r--app/assets/javascripts/toggles/index.js20
-rw-r--r--app/assets/javascripts/tracking/dispatch_snowplow_event.js7
-rw-r--r--app/assets/javascripts/tracking/tracking.js6
-rw-r--r--app/assets/javascripts/users_select/index.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue95
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue95
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue31
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue40
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js123
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue21
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/content_transition.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue25
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue112
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql13
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/utils.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue80
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue110
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue117
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue117
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue126
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue44
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue7
-rw-r--r--app/assets/javascripts/webpack_non_compiled_placeholder.js15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue62
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql14
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js23
-rw-r--r--app/assets/javascripts/work_items/graphql/resolvers.js43
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql8
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql14
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql12
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue34
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue36
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss17
-rw-r--r--app/assets/stylesheets/framework/files.scss4
-rw-r--r--app/assets/stylesheets/framework/highlight.scss2
-rw-r--r--app/assets/stylesheets/framework/mixins.scss6
-rw-r--r--app/assets/stylesheets/framework/typography.scss4
-rw-r--r--app/assets/stylesheets/notify.scss29
-rw-r--r--app/assets/stylesheets/notify_base.scss25
-rw-r--r--app/assets/stylesheets/notify_enhanced.scss68
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss8
-rw-r--r--app/assets/stylesheets/pages/issuable.scss9
-rw-r--r--app/assets/stylesheets/pages/projects.scss41
-rw-r--r--app/assets/stylesheets/pages/search.scss23
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss35
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss26
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss13
-rw-r--r--app/assets/stylesheets/themes/_dark.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss9
-rw-r--r--app/assets/stylesheets/utilities.scss28
-rw-r--r--app/components/pajamas/component.rb23
-rw-r--r--app/components/pajamas/toggle_component.html.haml16
-rw-r--r--app/components/pajamas/toggle_component.rb34
-rw-r--r--app/controllers/admin/application_settings_controller.rb9
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb2
-rw-r--r--app/controllers/admin/clusters_controller.rb1
-rw-r--r--app/controllers/admin/cohorts_controller.rb2
-rw-r--r--app/controllers/admin/dev_ops_report_controller.rb2
-rw-r--r--app/controllers/admin/instance_review_controller.rb2
-rw-r--r--app/controllers/admin/integrations_controller.rb4
-rw-r--r--app/controllers/admin/runner_projects_controller.rb5
-rw-r--r--app/controllers/admin/runners_controller.rb8
-rw-r--r--app/controllers/admin/usage_trends_controller.rb2
-rw-r--r--app/controllers/admin/users_controller.rb4
-rw-r--r--app/controllers/application_controller.rb15
-rw-r--r--app/controllers/autocomplete_controller.rb10
-rw-r--r--app/controllers/clusters/clusters_controller.rb26
-rw-r--r--app/controllers/concerns/floc_opt_out.rb2
-rw-r--r--app/controllers/concerns/issuable_actions.rb14
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb2
-rw-r--r--app/controllers/concerns/membership_actions.rb15
-rw-r--r--app/controllers/concerns/product_analytics_tracking.rb27
-rw-r--r--app/controllers/concerns/search_rate_limitable.rb15
-rw-r--r--app/controllers/concerns/sessionless_authentication.rb3
-rw-r--r--app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb11
-rw-r--r--app/controllers/concerns/spammable_actions/attributes.rb13
-rw-r--r--app/controllers/concerns/spammable_actions/captcha_check/common.rb32
-rw-r--r--app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb5
-rw-r--r--app/controllers/concerns/spammable_actions/captcha_check/json_format_actions_support.rb11
-rw-r--r--app/controllers/concerns/spammable_actions/captcha_check/rest_api_actions_support.rb39
-rw-r--r--app/controllers/concerns/uploads_actions.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb2
-rw-r--r--app/controllers/dashboard_controller.rb15
-rw-r--r--app/controllers/groups/application_controller.rb4
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups/clusters_controller.rb1
-rw-r--r--app/controllers/groups/crm/contacts_controller.rb1
-rw-r--r--app/controllers/groups/crm/organizations_controller.rb1
-rw-r--r--app/controllers/groups/deploy_tokens_controller.rb3
-rw-r--r--app/controllers/groups/group_members_controller.rb6
-rw-r--r--app/controllers/groups/harbor/repositories_controller.rb24
-rw-r--r--app/controllers/groups/releases_controller.rb16
-rw-r--r--app/controllers/groups/runners_controller.rb14
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb2
-rw-r--r--app/controllers/groups/settings/integrations_controller.rb4
-rw-r--r--app/controllers/groups/uploads_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb4
-rw-r--r--app/controllers/jira_connect/events_controller.rb30
-rw-r--r--app/controllers/jira_connect/oauth_callbacks_controller.rb11
-rw-r--r--app/controllers/jira_connect/subscriptions_controller.rb4
-rw-r--r--app/controllers/profiles_controller.rb2
-rw-r--r--app/controllers/projects/application_controller.rb7
-rw-r--r--app/controllers/projects/blob_controller.rb18
-rw-r--r--app/controllers/projects/boards_controller.rb1
-rw-r--r--app/controllers/projects/builds_controller.rb3
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb8
-rw-r--r--app/controllers/projects/ci/secure_files_controller.rb10
-rw-r--r--app/controllers/projects/cluster_agents_controller.rb4
-rw-r--r--app/controllers/projects/commit_controller.rb1
-rw-r--r--app/controllers/projects/commits_controller.rb2
-rw-r--r--app/controllers/projects/deploy_tokens_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb12
-rw-r--r--app/controllers/projects/error_tracking_controller.rb8
-rw-r--r--app/controllers/projects/forks_controller.rb8
-rw-r--r--app/controllers/projects/google_cloud/base_controller.rb27
-rw-r--r--app/controllers/projects/google_cloud/deployments_controller.rb5
-rw-r--r--app/controllers/projects/google_cloud/gcp_regions_controller.rb31
-rw-r--r--app/controllers/projects/google_cloud/revoke_oauth_controller.rb23
-rw-r--r--app/controllers/projects/google_cloud/service_accounts_controller.rb25
-rw-r--r--app/controllers/projects/google_cloud_controller.rb26
-rw-r--r--app/controllers/projects/harbor/application_controller.rb22
-rw-r--r--app/controllers/projects/harbor/repositories_controller.rb11
-rw-r--r--app/controllers/projects/incidents_controller.rb7
-rw-r--r--app/controllers/projects/issues_controller.rb30
-rw-r--r--app/controllers/projects/jobs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests_controller.rb21
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb4
-rw-r--r--app/controllers/projects/pipelines/stages_controller.rb4
-rw-r--r--app/controllers/projects/pipelines/tests_controller.rb4
-rw-r--r--app/controllers/projects/pipelines_controller.rb58
-rw-r--r--app/controllers/projects/project_members_controller.rb21
-rw-r--r--app/controllers/projects/redirect_controller.rb20
-rw-r--r--app/controllers/projects/releases_controller.rb33
-rw-r--r--app/controllers/projects/runner_projects_controller.rb5
-rw-r--r--app/controllers/projects/runners_controller.rb8
-rw-r--r--app/controllers/projects/serverless/functions_controller.rb5
-rw-r--r--app/controllers/projects/services_controller.rb4
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb2
-rw-r--r--app/controllers/projects/settings/operations_controller.rb4
-rw-r--r--app/controllers/projects/tags_controller.rb2
-rw-r--r--app/controllers/projects/tree_controller.rb1
-rw-r--r--app/controllers/projects_controller.rb27
-rw-r--r--app/controllers/registrations_controller.rb2
-rw-r--r--app/controllers/search_controller.rb10
-rw-r--r--app/controllers/uploads_controller.rb45
-rw-r--r--app/controllers/users_controller.rb4
-rw-r--r--app/events/repositories/keep_around_refs_created_event.rb14
-rw-r--r--app/experiments/application_experiment.rb14
-rw-r--r--app/experiments/combined_registration_experiment.rb11
-rw-r--r--app/experiments/in_product_guidance_environments_webide_experiment.rb6
-rw-r--r--app/experiments/new_project_sast_enabled_experiment.rb20
-rw-r--r--app/experiments/require_verification_for_namespace_creation_experiment.rb11
-rw-r--r--app/experiments/security_reports_mr_widget_prompt_experiment.rb8
-rw-r--r--app/finders/admin/projects_finder.rb2
-rw-r--r--app/finders/group_members_finder.rb7
-rw-r--r--app/finders/issuable_finder.rb10
-rw-r--r--app/finders/pending_todos_finder.rb9
-rw-r--r--app/finders/personal_access_tokens_finder.rb10
-rw-r--r--app/finders/projects/members/effective_access_level_finder.rb8
-rw-r--r--app/finders/projects/topics_finder.rb2
-rw-r--r--app/finders/releases/group_releases_finder.rb74
-rw-r--r--app/graphql/mutations/ci/pipeline/retry.rb5
-rw-r--r--app/graphql/mutations/ci/runner/delete.rb11
-rw-r--r--app/graphql/mutations/ci/runner/update.rb2
-rw-r--r--app/graphql/mutations/ci/runners_registration_token/reset.rb13
-rw-r--r--app/graphql/mutations/concerns/mutations/spam_protection.rb26
-rw-r--r--app/graphql/mutations/notes/base.rb6
-rw-r--r--app/graphql/mutations/notes/create/note.rb9
-rw-r--r--app/graphql/mutations/notes/update/base.rb6
-rw-r--r--app/graphql/mutations/saved_replies/base.rb39
-rw-r--r--app/graphql/mutations/saved_replies/create.rb26
-rw-r--r--app/graphql/mutations/saved_replies/update.rb31
-rw-r--r--app/graphql/mutations/work_items/create.rb2
-rw-r--r--app/graphql/mutations/work_items/create_from_task.rb64
-rw-r--r--app/graphql/mutations/work_items/delete.rb2
-rw-r--r--app/graphql/mutations/work_items/update.rb2
-rw-r--r--app/graphql/queries/burndown_chart/burnup.query.graphql9
-rw-r--r--app/graphql/resolvers/blobs_resolver.rb9
-rw-r--r--app/graphql/resolvers/ci/config_resolver.rb2
-rw-r--r--app/graphql/resolvers/concerns/group_issuable_resolver.rb23
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb3
-rw-r--r--app/graphql/resolvers/concerns/resolves_pipelines.rb13
-rw-r--r--app/graphql/resolvers/group_issues_resolver.rb6
-rw-r--r--app/graphql/resolvers/group_members/notification_email_resolver.rb29
-rw-r--r--app/graphql/resolvers/group_merge_requests_resolver.rb5
-rw-r--r--app/graphql/resolvers/topics_resolver.rb4
-rw-r--r--app/graphql/resolvers/work_item_resolver.rb29
-rw-r--r--app/graphql/resolvers/work_items/types_resolver.rb14
-rw-r--r--app/graphql/types/alert_management/alert_type.rb7
-rw-r--r--app/graphql/types/base_enum.rb3
-rw-r--r--app/graphql/types/board_list_type.rb16
-rw-r--r--app/graphql/types/ci/analytics_type.rb36
-rw-r--r--app/graphql/types/ci/ci_cd_setting_type.rb12
-rw-r--r--app/graphql/types/ci/config/group_type.rb4
-rw-r--r--app/graphql/types/ci/config/job_type.rb30
-rw-r--r--app/graphql/types/ci/config/stage_type.rb4
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb24
-rw-r--r--app/graphql/types/ci/group_type.rb8
-rw-r--r--app/graphql/types/ci/job_type.rb72
-rw-r--r--app/graphql/types/ci/runner_architecture_type.rb4
-rw-r--r--app/graphql/types/ci/runner_platform_type.rb8
-rw-r--r--app/graphql/types/ci/runner_type.rb86
-rw-r--r--app/graphql/types/ci/runner_web_url_edge.rb19
-rw-r--r--app/graphql/types/ci/stage_type.rb12
-rw-r--r--app/graphql/types/ci/status_action_type.rb6
-rw-r--r--app/graphql/types/ci/template_type.rb4
-rw-r--r--app/graphql/types/commit_action_type.rb16
-rw-r--r--app/graphql/types/commit_type.rb6
-rw-r--r--app/graphql/types/container_expiration_policy_type.rb6
-rw-r--r--app/graphql/types/container_repository_details_type.rb11
-rw-r--r--app/graphql/types/container_repository_tag_type.rb8
-rw-r--r--app/graphql/types/container_repository_type.rb14
-rw-r--r--app/graphql/types/dependency_proxy/blob_type.rb2
-rw-r--r--app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb2
-rw-r--r--app/graphql/types/dependency_proxy/manifest_type.rb6
-rw-r--r--app/graphql/types/design_management/design_collection_type.rb4
-rw-r--r--app/graphql/types/design_management/design_fields.rb2
-rw-r--r--app/graphql/types/design_management/design_type.rb10
-rw-r--r--app/graphql/types/diff_paths_input_type.rb4
-rw-r--r--app/graphql/types/diff_refs_type.rb4
-rw-r--r--app/graphql/types/diff_stats_summary_type.rb4
-rw-r--r--app/graphql/types/diff_stats_type.rb4
-rw-r--r--app/graphql/types/error_tracking/sentry_detailed_error_type.rb114
-rw-r--r--app/graphql/types/error_tracking/sentry_error_collection_type.rb6
-rw-r--r--app/graphql/types/error_tracking/sentry_error_frequency_type.rb6
-rw-r--r--app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb8
-rw-r--r--app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb10
-rw-r--r--app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb6
-rw-r--r--app/graphql/types/error_tracking/sentry_error_type.rb60
-rw-r--r--app/graphql/types/evidence_type.rb8
-rw-r--r--app/graphql/types/global_id_type.rb6
-rw-r--r--app/graphql/types/grafana_integration_type.rb12
-rw-r--r--app/graphql/types/group_member_type.rb4
-rw-r--r--app/graphql/types/group_type.rb5
-rw-r--r--app/graphql/types/issue_type.rb45
-rw-r--r--app/graphql/types/issues/negated_issue_filter_input_type.rb24
-rw-r--r--app/graphql/types/jira_import_type.rb12
-rw-r--r--app/graphql/types/jira_user_type.rb12
-rw-r--r--app/graphql/types/jira_users_mapping_input_type.rb8
-rw-r--r--app/graphql/types/label_type.rb16
-rw-r--r--app/graphql/types/merge_request_type.rb217
-rw-r--r--app/graphql/types/merge_requests/assignee_type.rb1
-rw-r--r--app/graphql/types/merge_requests/author_type.rb14
-rw-r--r--app/graphql/types/merge_requests/participant_type.rb14
-rw-r--r--app/graphql/types/merge_requests/reviewer_type.rb1
-rw-r--r--app/graphql/types/metadata/kas_type.rb4
-rw-r--r--app/graphql/types/metadata_type.rb8
-rw-r--r--app/graphql/types/metrics/dashboards/annotation_type.rb7
-rw-r--r--app/graphql/types/mutation_type.rb3
-rw-r--r--app/graphql/types/namespace/package_settings_type.rb6
-rw-r--r--app/graphql/types/namespace_type.rb20
-rw-r--r--app/graphql/types/notes/diff_image_position_input_type.rb8
-rw-r--r--app/graphql/types/notes/diff_position_base_input_type.rb4
-rw-r--r--app/graphql/types/notes/diff_position_input_type.rb6
-rw-r--r--app/graphql/types/notes/diff_position_type.rb16
-rw-r--r--app/graphql/types/notes/discussion_type.rb12
-rw-r--r--app/graphql/types/notes/note_type.rb10
-rw-r--r--app/graphql/types/packages/composer/json_type.rb2
-rw-r--r--app/graphql/types/packages/composer/metadatum_type.rb2
-rw-r--r--app/graphql/types/packages/conan/file_metadatum_type.rb6
-rw-r--r--app/graphql/types/packages/conan/metadatum_type.rb6
-rw-r--r--app/graphql/types/packages/helm/dependency_type.rb10
-rw-r--r--app/graphql/types/packages/helm/maintainer_type.rb2
-rw-r--r--app/graphql/types/packages/helm/metadata_type.rb24
-rw-r--r--app/graphql/types/packages/maven/metadatum_type.rb10
-rw-r--r--app/graphql/types/packages/nuget/metadatum_type.rb2
-rw-r--r--app/graphql/types/packages/package_dependency_link_type.rb4
-rw-r--r--app/graphql/types/packages/package_file_type.rb12
-rw-r--r--app/graphql/types/packages/package_tag_type.rb2
-rw-r--r--app/graphql/types/packages/package_type.rb18
-rw-r--r--app/graphql/types/project_statistics_type.rb20
-rw-r--r--app/graphql/types/project_type.rb63
-rw-r--r--app/graphql/types/projects/service_type.rb11
-rw-r--r--app/graphql/types/projects/service_type_enum.rb17
-rw-r--r--app/graphql/types/projects/services/jira_project_type.rb4
-rw-r--r--app/graphql/types/query_type.rb14
-rw-r--r--app/graphql/types/release_asset_link_type.rb12
-rw-r--r--app/graphql/types/release_links_type.rb20
-rw-r--r--app/graphql/types/release_type.rb32
-rw-r--r--app/graphql/types/repository/blob_type.rb12
-rw-r--r--app/graphql/types/repository_type.rb22
-rw-r--r--app/graphql/types/root_storage_statistics_type.rb12
-rw-r--r--app/graphql/types/saved_reply_type.rb21
-rw-r--r--app/graphql/types/task_completion_status.rb4
-rw-r--r--app/graphql/types/todo_type.rb30
-rw-r--r--app/graphql/types/todoable_interface.rb30
-rw-r--r--app/graphql/types/tree/blob_type.rb8
-rw-r--r--app/graphql/types/tree/submodule_type.rb4
-rw-r--r--app/graphql/types/tree/tree_entry_type.rb4
-rw-r--r--app/graphql/types/user_callout_type.rb4
-rw-r--r--app/graphql/types/user_interface.rb22
-rw-r--r--app/graphql/types/user_status_type.rb8
-rw-r--r--app/graphql/types/work_item_id_type.rb50
-rw-r--r--app/graphql/types/work_item_type.rb4
-rw-r--r--app/graphql/types/work_items/convert_task_input_type.rb36
-rw-r--r--app/helpers/access_tokens_helper.rb6
-rw-r--r--app/helpers/appearances_helper.rb2
-rw-r--r--app/helpers/application_helper.rb22
-rw-r--r--app/helpers/application_settings_helper.rb9
-rw-r--r--app/helpers/auth_helper.rb2
-rw-r--r--app/helpers/blob_helper.rb37
-rw-r--r--app/helpers/broadcast_messages_helper.rb43
-rw-r--r--app/helpers/ci/jobs_helper.rb3
-rw-r--r--app/helpers/ci/pipelines_helper.rb31
-rw-r--r--app/helpers/clusters_helper.rb42
-rw-r--r--app/helpers/commits_helper.rb13
-rw-r--r--app/helpers/container_expiration_policies_helper.rb5
-rw-r--r--app/helpers/container_registry_helper.rb3
-rw-r--r--app/helpers/dashboard_helper.rb4
-rw-r--r--app/helpers/deploy_tokens_helper.rb7
-rw-r--r--app/helpers/diff_helper.rb4
-rw-r--r--app/helpers/dropdowns_helper.rb2
-rw-r--r--app/helpers/explore_helper.rb37
-rw-r--r--app/helpers/groups/crm_settings_helper.rb2
-rw-r--r--app/helpers/groups/group_members_helper.rb4
-rw-r--r--app/helpers/icons_helper.rb40
-rw-r--r--app/helpers/integrations_helper.rb33
-rw-r--r--app/helpers/invite_members_helper.rb4
-rw-r--r--app/helpers/issues_helper.rb6
-rw-r--r--app/helpers/jira_connect_helper.rb28
-rw-r--r--app/helpers/labels_helper.rb26
-rw-r--r--app/helpers/lazy_image_tag_helper.rb3
-rw-r--r--app/helpers/learn_gitlab_helper.rb38
-rw-r--r--app/helpers/listbox_helper.rb13
-rw-r--r--app/helpers/markup_helper.rb7
-rw-r--r--app/helpers/merge_requests_helper.rb15
-rw-r--r--app/helpers/packages_helper.rb2
-rw-r--r--app/helpers/pagination_helper.rb4
-rw-r--r--app/helpers/preferences_helper.rb4
-rw-r--r--app/helpers/projects/cluster_agents_helper.rb4
-rw-r--r--app/helpers/projects/error_tracking_helper.rb14
-rw-r--r--app/helpers/projects_helper.rb21
-rw-r--r--app/helpers/routing/pseudonymization_helper.rb6
-rw-r--r--app/helpers/sessions_helper.rb2
-rw-r--r--app/helpers/sorting_helper.rb10
-rw-r--r--app/helpers/storage_helper.rb17
-rw-r--r--app/helpers/submodule_helper.rb2
-rw-r--r--app/helpers/todos_helper.rb2
-rw-r--r--app/helpers/tree_helper.rb2
-rw-r--r--app/helpers/users/callouts_helper.rb4
-rw-r--r--app/helpers/web_ide_button_helper.rb4
-rw-r--r--app/helpers/whats_new_helper.rb2
-rw-r--r--app/mailers/application_mailer.rb1
-rw-r--r--app/mailers/emails/profile.rb12
-rw-r--r--app/models/analytics/cycle_analytics/aggregation.rb75
-rw-r--r--app/models/application_record.rb11
-rw-r--r--app/models/application_setting.rb21
-rw-r--r--app/models/application_setting_implementation.rb9
-rw-r--r--app/models/blobs/notebook.rb12
-rw-r--r--app/models/broadcast_message.rb40
-rw-r--r--app/models/bulk_imports/entity.rb2
-rw-r--r--app/models/bulk_imports/export_status.rb7
-rw-r--r--app/models/ci/bridge.rb70
-rw-r--r--app/models/ci/build.rb16
-rw-r--r--app/models/ci/group_variable.rb1
-rw-r--r--app/models/ci/pipeline.rb13
-rw-r--r--app/models/ci/pipeline_schedule.rb12
-rw-r--r--app/models/ci/processable.rb2
-rw-r--r--app/models/ci/runner.rb8
-rw-r--r--app/models/ci/secure_file.rb4
-rw-r--r--app/models/concerns/blocks_json_serialization.rb18
-rw-r--r--app/models/concerns/blocks_unsafe_serialization.rb32
-rw-r--r--app/models/concerns/bulk_member_access_load.rb52
-rw-r--r--app/models/concerns/ci/has_deployment_name.rb15
-rw-r--r--app/models/concerns/ci/has_status.rb6
-rw-r--r--app/models/concerns/counter_attribute.rb26
-rw-r--r--app/models/concerns/deployment_platform.rb2
-rw-r--r--app/models/concerns/has_user_type.rb13
-rw-r--r--app/models/concerns/integrations/has_issue_tracker_fields.rb30
-rw-r--r--app/models/concerns/issuable.rb60
-rw-r--r--app/models/concerns/issuable_link.rb55
-rw-r--r--app/models/concerns/issue_resource_event.rb6
-rw-r--r--app/models/concerns/merge_request_reviewer_state.rb8
-rw-r--r--app/models/concerns/pg_full_text_searchable.rb111
-rw-r--r--app/models/concerns/runners_token_prefixable.rb6
-rw-r--r--app/models/concerns/select_for_project_authorization.rb6
-rw-r--r--app/models/concerns/sensitive_serializable_hash.rb46
-rw-r--r--app/models/concerns/spammable.rb11
-rw-r--r--app/models/concerns/timebox.rb1
-rw-r--r--app/models/concerns/token_authenticatable.rb10
-rw-r--r--app/models/concerns/token_authenticatable_strategies/base.rb8
-rw-r--r--app/models/concerns/token_authenticatable_strategies/digest.rb4
-rw-r--r--app/models/concerns/token_authenticatable_strategies/encrypted.rb4
-rw-r--r--app/models/concerns/update_namespace_statistics.rb54
-rw-r--r--app/models/container_repository.rb16
-rw-r--r--app/models/customer_relations/contact.rb18
-rw-r--r--app/models/customer_relations/issue_contact.rb8
-rw-r--r--app/models/customer_relations/organization.rb9
-rw-r--r--app/models/dependency_proxy/blob.rb3
-rw-r--r--app/models/dependency_proxy/manifest.rb3
-rw-r--r--app/models/deployment.rb1
-rw-r--r--app/models/diff_note.rb2
-rw-r--r--app/models/environment.rb15
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb4
-rw-r--r--app/models/event_collection.rb48
-rw-r--r--app/models/group.rb10
-rw-r--r--app/models/hooks/web_hook.rb15
-rw-r--r--app/models/instance_configuration.rb3
-rw-r--r--app/models/integration.rb129
-rw-r--r--app/models/integrations/asana.rb4
-rw-r--r--app/models/integrations/bamboo.rb3
-rw-r--r--app/models/integrations/base_chat_notification.rb1
-rw-r--r--app/models/integrations/base_issue_tracker.rb26
-rw-r--r--app/models/integrations/bugzilla.rb4
-rw-r--r--app/models/integrations/campfire.rb4
-rw-r--r--app/models/integrations/confluence.rb4
-rw-r--r--app/models/integrations/custom_issue_tracker.rb5
-rw-r--r--app/models/integrations/discord.rb4
-rw-r--r--app/models/integrations/ewm.rb4
-rw-r--r--app/models/integrations/external_wiki.rb4
-rw-r--r--app/models/integrations/field.rb42
-rw-r--r--app/models/integrations/flowdock.rb4
-rw-r--r--app/models/integrations/hangouts_chat.rb4
-rw-r--r--app/models/integrations/harbor.rb104
-rw-r--r--app/models/integrations/irker.rb6
-rw-r--r--app/models/integrations/jenkins.rb4
-rw-r--r--app/models/integrations/jira.rb110
-rw-r--r--app/models/integrations/mattermost.rb3
-rw-r--r--app/models/integrations/pivotaltracker.rb3
-rw-r--r--app/models/integrations/prometheus.rb1
-rw-r--r--app/models/integrations/redmine.rb5
-rw-r--r--app/models/integrations/webex_teams.rb4
-rw-r--r--app/models/integrations/youtrack.rb4
-rw-r--r--app/models/integrations/zentao.rb5
-rw-r--r--app/models/issue.rb14
-rw-r--r--app/models/issue_link.rb37
-rw-r--r--app/models/issues/search_data.rb11
-rw-r--r--app/models/label.rb25
-rw-r--r--app/models/lfs_download_object.rb1
-rw-r--r--app/models/members/project_member.rb6
-rw-r--r--app/models/merge_request.rb45
-rw-r--r--app/models/milestone.rb1
-rw-r--r--app/models/namespace.rb32
-rw-r--r--app/models/namespace/traversal_hierarchy.rb17
-rw-r--r--app/models/namespaces/traversal/linear.rb9
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb28
-rw-r--r--app/models/note.rb1
-rw-r--r--app/models/packages/package_file.rb1
-rw-r--r--app/models/packages/pypi/metadatum.rb2
-rw-r--r--app/models/personal_access_token.rb1
-rw-r--r--app/models/preloaders/environments/deployment_preloader.rb22
-rw-r--r--app/models/project.rb79
-rw-r--r--app/models/project_authorization.rb2
-rw-r--r--app/models/project_import_data.rb7
-rw-r--r--app/models/project_pages_metadatum.rb4
-rw-r--r--app/models/project_team.rb44
-rw-r--r--app/models/projects/build_artifacts_size_refresh.rb91
-rw-r--r--app/models/projects/topic.rb7
-rw-r--r--app/models/projects/triggered_hooks.rb25
-rw-r--r--app/models/release.rb4
-rw-r--r--app/models/repository.rb5
-rw-r--r--app/models/snippet.rb16
-rw-r--r--app/models/storage/hashed.rb1
-rw-r--r--app/models/storage/legacy_project.rb1
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/user.rb79
-rw-r--r--app/models/users/callout.rb8
-rw-r--r--app/models/users/credit_card_validation.rb2
-rw-r--r--app/models/users/group_callout.rb8
-rw-r--r--app/models/users/saved_reply.rb19
-rw-r--r--app/models/wiki.rb17
-rw-r--r--app/models/wiki_page.rb6
-rw-r--r--app/models/work_item.rb8
-rw-r--r--app/models/work_items/type.rb1
-rw-r--r--app/policies/alert_management/alert_policy.rb2
-rw-r--r--app/policies/application_setting_policy.rb5
-rw-r--r--app/policies/base_policy.rb2
-rw-r--r--app/policies/ci/runner_policy.rb6
-rw-r--r--app/policies/global_policy.rb1
-rw-r--r--app/policies/group_policy.rb7
-rw-r--r--app/policies/issue_policy.rb2
-rw-r--r--app/policies/project_policy.rb15
-rw-r--r--app/policies/user_policy.rb3
-rw-r--r--app/policies/users/saved_reply_policy.rb7
-rw-r--r--app/policies/work_item_policy.rb11
-rw-r--r--app/presenters/alert_management/alert_presenter.rb1
-rw-r--r--app/presenters/blob_presenter.rb21
-rw-r--r--app/presenters/blobs/notebook_presenter.rb9
-rw-r--r--app/presenters/ci/build_runner_presenter.rb5
-rw-r--r--app/presenters/ci/pipeline_presenter.rb1
-rw-r--r--app/presenters/clusterable_presenter.rb4
-rw-r--r--app/presenters/environment_presenter.rb2
-rw-r--r--app/presenters/gitlab/blame_presenter.rb9
-rw-r--r--app/presenters/instance_clusterable_presenter.rb5
-rw-r--r--app/presenters/label_presenter.rb4
-rw-r--r--app/presenters/merge_request_presenter.rb21
-rw-r--r--app/presenters/project_clusterable_presenter.rb4
-rw-r--r--app/presenters/project_presenter.rb17
-rw-r--r--app/presenters/release_presenter.rb6
-rw-r--r--app/presenters/releases/evidence_presenter.rb2
-rw-r--r--app/presenters/search_service_presenter.rb2
-rw-r--r--app/presenters/user_presenter.rb20
-rw-r--r--app/serializers/analytics/cycle_analytics/stage_entity.rb3
-rw-r--r--app/serializers/cluster_entity.rb2
-rw-r--r--app/serializers/cluster_error_entity.rb7
-rw-r--r--app/serializers/clusters/kubernetes_error_entity.rb9
-rw-r--r--app/serializers/deployment_entity.rb2
-rw-r--r--app/serializers/diffs_entity.rb4
-rw-r--r--app/serializers/environment_entity.rb1
-rw-r--r--app/serializers/environment_serializer.rb2
-rw-r--r--app/serializers/fork_namespace_entity.rb8
-rw-r--r--app/serializers/issue_sidebar_basic_entity.rb5
-rw-r--r--app/serializers/label_entity.rb4
-rw-r--r--app/serializers/member_entity.rb2
-rw-r--r--app/serializers/merge_request_widget_entity.rb6
-rw-r--r--app/serializers/pipeline_details_entity.rb5
-rw-r--r--app/serializers/service_event_entity.rb2
-rw-r--r--app/serializers/service_field_entity.rb2
-rw-r--r--app/services/alert_management/create_alert_issue_service.rb12
-rw-r--r--app/services/auth/container_registry_authentication_service.rb36
-rw-r--r--app/services/boards/base_items_list_service.rb27
-rw-r--r--app/services/bulk_create_integration_service.rb6
-rw-r--r--app/services/ci/after_requeue_job_service.rb28
-rw-r--r--app/services/ci/create_downstream_pipeline_service.rb14
-rw-r--r--app/services/ci/destroy_secure_file_service.rb11
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb8
-rw-r--r--app/services/ci/register_runner_service.rb58
-rw-r--r--app/services/ci/retry_build_service.rb2
-rw-r--r--app/services/ci/retry_pipeline_service.rb17
-rw-r--r--app/services/ci/runners/assign_runner_service.rb28
-rw-r--r--app/services/ci/runners/register_runner_service.rb60
-rw-r--r--app/services/ci/runners/reset_registration_token_service.rb31
-rw-r--r--app/services/ci/runners/unassign_runner_service.rb28
-rw-r--r--app/services/ci/runners/unregister_runner_service.rb22
-rw-r--r--app/services/ci/runners/update_runner_service.rb21
-rw-r--r--app/services/ci/test_failure_history_service.rb1
-rw-r--r--app/services/ci/unregister_runner_service.rb16
-rw-r--r--app/services/ci/update_runner_service.rb19
-rw-r--r--app/services/concerns/members/bulk_create_users.rb13
-rw-r--r--app/services/concerns/rate_limited_service.rb6
-rw-r--r--app/services/concerns/update_repository_storage_methods.rb1
-rw-r--r--app/services/error_tracking/base_service.rb10
-rw-r--r--app/services/error_tracking/collect_error_service.rb2
-rw-r--r--app/services/google_cloud/create_service_accounts_service.rb7
-rw-r--r--app/services/google_cloud/gcp_region_add_or_replace_service.rb23
-rw-r--r--app/services/google_cloud/service_accounts_service.rb33
-rw-r--r--app/services/groups/deploy_tokens/create_service.rb2
-rw-r--r--app/services/groups/deploy_tokens/destroy_service.rb2
-rw-r--r--app/services/groups/deploy_tokens/revoke_service.rb16
-rw-r--r--app/services/groups/destroy_service.rb4
-rw-r--r--app/services/import/gitlab_projects/create_project_from_remote_file_service.rb91
-rw-r--r--app/services/import/gitlab_projects/create_project_from_uploaded_file_service.rb65
-rw-r--r--app/services/import/gitlab_projects/create_project_service.rb81
-rw-r--r--app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb33
-rw-r--r--app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb76
-rw-r--r--app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb93
-rw-r--r--app/services/incident_management/pager_duty/process_webhook_service.rb7
-rw-r--r--app/services/integrations/propagate_template_service.rb10
-rw-r--r--app/services/issuable_base_service.rb7
-rw-r--r--app/services/issuable_links/create_service.rb43
-rw-r--r--app/services/issuable_links/destroy_service.rb9
-rw-r--r--app/services/issue_links/create_service.rb27
-rw-r--r--app/services/issue_links/destroy_service.rb13
-rw-r--r--app/services/issues/create_service.rb8
-rw-r--r--app/services/issues/export_csv_service.rb6
-rw-r--r--app/services/issues/set_crm_contacts_service.rb2
-rw-r--r--app/services/issues/update_service.rb12
-rw-r--r--app/services/labels/base_service.rb156
-rw-r--r--app/services/loose_foreign_keys/batch_cleaner_service.rb2
-rw-r--r--app/services/members/projects/creator_service.rb2
-rw-r--r--app/services/merge_requests/approval_service.rb7
-rw-r--r--app/services/merge_requests/base_service.rb8
-rw-r--r--app/services/merge_requests/bulk_remove_attention_requested_service.rb2
-rw-r--r--app/services/merge_requests/create_service.rb8
-rw-r--r--app/services/merge_requests/export_csv_service.rb4
-rw-r--r--app/services/merge_requests/handle_assignees_change_service.rb4
-rw-r--r--app/services/merge_requests/merge_orchestration_service.rb2
-rw-r--r--app/services/merge_requests/mergeability/check_broken_status_service.rb22
-rw-r--r--app/services/merge_requests/mergeability/check_discussions_status_service.rb22
-rw-r--r--app/services/merge_requests/mergeability/check_draft_status_service.rb23
-rw-r--r--app/services/merge_requests/mergeability/check_open_status_service.rb23
-rw-r--r--app/services/merge_requests/mergeability/run_checks_service.rb4
-rw-r--r--app/services/merge_requests/reload_merge_head_diff_service.rb5
-rw-r--r--app/services/merge_requests/remove_approval_service.rb2
-rw-r--r--app/services/merge_requests/remove_attention_requested_service.rb11
-rw-r--r--app/services/merge_requests/reopen_service.rb6
-rw-r--r--app/services/merge_requests/toggle_attention_requested_service.rb7
-rw-r--r--app/services/notification_recipients/builder/merge_request_unmergeable.rb1
-rw-r--r--app/services/notification_recipients/builder/new_note.rb1
-rw-r--r--app/services/notification_recipients/builder/new_review.rb1
-rw-r--r--app/services/notification_recipients/builder/project_maintainers.rb1
-rw-r--r--app/services/notification_service.rb8
-rw-r--r--app/services/packages/pypi/create_package_service.rb2
-rw-r--r--app/services/personal_access_tokens/create_service.rb1
-rw-r--r--app/services/post_receive_service.rb2
-rw-r--r--app/services/projects/base_move_relations_service.rb1
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb8
-rw-r--r--app/services/projects/container_repository/gitlab/delete_tags_service.rb2
-rw-r--r--app/services/projects/container_repository/third_party/delete_tags_service.rb8
-rw-r--r--app/services/projects/create_service.rb2
-rw-r--r--app/services/projects/deploy_tokens/create_service.rb2
-rw-r--r--app/services/projects/deploy_tokens/destroy_service.rb2
-rw-r--r--app/services/projects/destroy_service.rb14
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_service.rb1
-rw-r--r--app/services/projects/refresh_build_artifacts_size_statistics_service.rb32
-rw-r--r--app/services/projects/update_pages_service.rb14
-rw-r--r--app/services/repositories/base_service.rb2
-rw-r--r--app/services/repositories/destroy_rollback_service.rb8
-rw-r--r--app/services/repositories/destroy_service.rb6
-rw-r--r--app/services/security/ci_configuration/base_create_service.rb2
-rw-r--r--app/services/security/ci_configuration/container_scanning_create_service.rb3
-rw-r--r--app/services/security/ci_configuration/dependency_scanning_create_service.rb3
-rw-r--r--app/services/security/ci_configuration/sast_create_service.rb2
-rw-r--r--app/services/security/ci_configuration/sast_iac_create_service.rb3
-rw-r--r--app/services/security/ci_configuration/secret_detection_create_service.rb3
-rw-r--r--app/services/security/merge_reports_service.rb5
-rw-r--r--app/services/spam/spam_action_service.rb23
-rw-r--r--app/services/spam/spam_constants.rb18
-rw-r--r--app/services/spam/spam_params.rb10
-rw-r--r--app/services/spam/spam_verdict_service.rb17
-rw-r--r--app/services/system_note_service.rb8
-rw-r--r--app/services/system_notes/issuables_service.rb14
-rw-r--r--app/services/todo_service.rb21
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb21
-rw-r--r--app/services/users/saved_replies/create_service.rb29
-rw-r--r--app/services/users/saved_replies/update_service.rb26
-rw-r--r--app/services/web_hooks/log_execution_service.rb74
-rw-r--r--app/services/work_items/create_and_link_service.rb43
-rw-r--r--app/services/work_items/create_from_task_service.rb50
-rw-r--r--app/services/work_items/task_list_reference_replacement_service.rb52
-rw-r--r--app/uploaders/content_type_whitelist.rb2
-rw-r--r--app/validators/color_validator.rb10
-rw-r--r--app/validators/import/gitlab_projects/remote_file_validator.rb45
-rw-r--r--app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json4
-rw-r--r--app/views/admin/abuse_reports/_abuse_report.html.haml4
-rw-r--r--app/views/admin/application_settings/_default_branch.html.haml (renamed from app/views/admin/application_settings/_initial_branch_name.html.haml)8
-rw-r--r--app/views/admin/application_settings/_eks.html.haml8
-rw-r--r--app/views/admin/application_settings/_prometheus.html.haml2
-rw-r--r--app/views/admin/application_settings/_registry.html.haml8
-rw-r--r--app/views/admin/application_settings/_search_limits.html.haml16
-rw-r--r--app/views/admin/application_settings/_signin.html.haml8
-rw-r--r--app/views/admin/application_settings/_sourcegraph.html.haml6
-rw-r--r--app/views/admin/application_settings/_usage.html.haml2
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml3
-rw-r--r--app/views/admin/application_settings/appearances/_form.html.haml6
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/network.html.haml11
-rw-r--r--app/views/admin/application_settings/repository.html.haml6
-rw-r--r--app/views/admin/applications/_delete_form.html.haml9
-rw-r--r--app/views/admin/applications/index.html.haml2
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml15
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml12
-rw-r--r--app/views/admin/dashboard/_security_newsletter_callout.html.haml1
-rw-r--r--app/views/admin/groups/_form.html.haml9
-rw-r--r--app/views/admin/hooks/_form.html.haml2
-rw-r--r--app/views/admin/runners/edit.html.haml15
-rw-r--r--app/views/admin/spam_logs/_spam_log.html.haml2
-rw-r--r--app/views/admin/topics/_form.html.haml2
-rw-r--r--app/views/admin/users/_head.html.haml4
-rw-r--r--app/views/admin/users/_users.html.haml17
-rw-r--r--app/views/ci/variables/_variable_row.html.haml27
-rw-r--r--app/views/clusters/clusters/_banner.html.haml2
-rw-r--r--app/views/clusters/clusters/_cluster_list.html.haml10
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml19
-rw-r--r--app/views/clusters/clusters/_multiple_clusters_message.html.haml4
-rw-r--r--app/views/clusters/clusters/_sidebar.html.haml4
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml6
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml8
-rw-r--r--app/views/clusters/clusters/connect.html.haml11
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml6
-rw-r--r--app/views/clusters/clusters/index.html.haml8
-rw-r--r--app/views/clusters/clusters/new.html.haml30
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml8
-rw-r--r--app/views/dashboard/_activities.html.haml2
-rw-r--r--app/views/dashboard/groups/_groups.html.haml2
-rw-r--r--app/views/dashboard/todos/_todo.html.haml6
-rw-r--r--app/views/dashboard/todos/index.html.haml5
-rw-r--r--app/views/devise/shared/_email_opted_in.html.haml2
-rw-r--r--app/views/devise/shared/_terms_of_service_notice.html.haml2
-rw-r--r--app/views/discussions/_discussion.html.haml3
-rw-r--r--app/views/explore/groups/_groups.html.haml3
-rw-r--r--app/views/explore/projects/_filter.html.haml23
-rw-r--r--app/views/groups/_activities.html.haml2
-rw-r--r--app/views/groups/_archived_projects.html.haml3
-rw-r--r--app/views/groups/_import_group_from_another_instance_panel.html.haml3
-rw-r--r--app/views/groups/_shared_projects.html.haml3
-rw-r--r--app/views/groups/group_members/index.html.haml35
-rw-r--r--app/views/groups/harbor/repositories/index.html.haml9
-rw-r--r--app/views/groups/imports/show.html.haml2
-rw-r--r--app/views/groups/registry/repositories/index.html.haml1
-rw-r--r--app/views/groups/runners/_runner.html.haml8
-rw-r--r--app/views/groups/runners/_settings.html.haml14
-rw-r--r--app/views/groups/runners/edit.html.haml8
-rw-r--r--app/views/groups/runners/show.html.haml5
-rw-r--r--app/views/groups/settings/_permissions.html.haml3
-rw-r--r--app/views/groups/settings/_transfer.html.haml2
-rw-r--r--app/views/groups/settings/repository/_default_branch.html.haml (renamed from app/views/groups/settings/repository/_initial_branch_name.html.haml)14
-rw-r--r--app/views/groups/settings/repository/show.html.haml2
-rw-r--r--app/views/ide/_show.html.haml2
-rw-r--r--app/views/import/shared/_errors.html.haml14
-rw-r--r--app/views/jira_connect/oauth_callbacks/index.html.haml1
-rw-r--r--app/views/layouts/_head.html.haml3
-rw-r--r--app/views/layouts/_header_search.html.haml24
-rw-r--r--app/views/layouts/_page.html.haml29
-rw-r--r--app/views/layouts/group.html.haml3
-rw-r--r--app/views/layouts/header/_default.html.haml21
-rw-r--r--app/views/layouts/header/_registration_enabled_callout.html.haml9
-rw-r--r--app/views/layouts/header/_storage_enforcement_banner.html.haml9
-rw-r--r--app/views/layouts/header/_translations.html.haml1
-rw-r--r--app/views/layouts/notify.html.haml5
-rw-r--r--app/views/layouts/profile.html.haml4
-rw-r--r--app/views/layouts/service_desk.html.haml5
-rw-r--r--app/views/notify/_note_email.html.haml4
-rw-r--r--app/views/notify/access_token_created_email.html.haml7
-rw-r--r--app/views/notify/access_token_created_email.text.erb5
-rw-r--r--app/views/notify/issue_due_email.html.haml4
-rw-r--r--app/views/notify/new_issue_email.html.haml4
-rw-r--r--app/views/notify/new_merge_request_email.html.haml2
-rw-r--r--app/views/notify/new_release_email.html.haml2
-rw-r--r--app/views/notify/service_desk_new_note_email.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml2
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml2
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml12
-rw-r--r--app/views/projects/_activity.html.haml2
-rw-r--r--app/views/projects/_commit_button.html.haml2
-rw-r--r--app/views/projects/_deletion_failed.html.haml11
-rw-r--r--app/views/projects/_files.html.haml3
-rw-r--r--app/views/projects/_gitlab_import_modal.html.haml14
-rw-r--r--app/views/projects/_import_project_pane.html.haml7
-rw-r--r--app/views/projects/_invite_groups_modal.html.haml2
-rw-r--r--app/views/projects/_last_push.html.haml8
-rw-r--r--app/views/projects/_merge_request_merge_method_settings.html.haml4
-rw-r--r--app/views/projects/_new_project_fields.html.haml16
-rw-r--r--app/views/projects/_project_templates.html.haml9
-rw-r--r--app/views/projects/artifacts/_artifact.html.haml2
-rw-r--r--app/views/projects/blob/_blob.html.haml3
-rw-r--r--app/views/projects/blob/_editor.html.haml3
-rw-r--r--app/views/projects/blob/_header.html.haml8
-rw-r--r--app/views/projects/blob/_upload.html.haml4
-rw-r--r--app/views/projects/blob/edit.html.haml18
-rw-r--r--app/views/projects/blob/new.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_loading.html.haml3
-rw-r--r--app/views/projects/blob/viewers/_loading_auxiliary.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_route_map_loading.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_sketch.html.haml3
-rw-r--r--app/views/projects/blob/viewers/_stl.html.haml2
-rw-r--r--app/views/projects/branches/new.html.haml3
-rw-r--r--app/views/projects/ci/secure_files/show.html.haml5
-rw-r--r--app/views/projects/commit/_commit_box.html.haml4
-rw-r--r--app/views/projects/commits/_commits.html.haml13
-rw-r--r--app/views/projects/commits/show.html.haml3
-rw-r--r--app/views/projects/diffs/_content.html.haml11
-rw-r--r--app/views/projects/diffs/_file.html.haml5
-rw-r--r--app/views/projects/diffs/_line.html.haml7
-rw-r--r--app/views/projects/diffs/_warning.html.haml1
-rw-r--r--app/views/projects/edit.html.haml4
-rw-r--r--app/views/projects/empty.html.haml81
-rw-r--r--app/views/projects/environments/index.html.haml24
-rw-r--r--app/views/projects/find_file/show.html.haml3
-rw-r--r--app/views/projects/forks/_fork_button.html.haml20
-rw-r--r--app/views/projects/forks/error.html.haml1
-rw-r--r--app/views/projects/forks/new.html.haml39
-rw-r--r--app/views/projects/google_cloud/gcp_regions/index.html.haml8
-rw-r--r--app/views/projects/harbor/repositories/index.html.haml9
-rw-r--r--app/views/projects/imports/show.html.haml2
-rw-r--r--app/views/projects/issues/_alert_moved_from_service_desk.html.haml1
-rw-r--r--app/views/projects/issues/_form.html.haml2
-rw-r--r--app/views/projects/issues/_service_desk_empty_state.html.haml4
-rw-r--r--app/views/projects/issues/index.html.haml2
-rw-r--r--app/views/projects/learn_gitlab/index.html.haml6
-rw-r--r--app/views/projects/merge_requests/_commits.html.haml4
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml16
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml6
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml2
-rw-r--r--app/views/projects/merge_requests/invalid.html.haml25
-rw-r--r--app/views/projects/merge_requests/show.html.haml9
-rw-r--r--app/views/projects/milestones/show.html.haml1
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml5
-rw-r--r--app/views/projects/network/show.html.haml3
-rw-r--r--app/views/projects/pages/_ssl_limitations_warning.html.haml4
-rw-r--r--app/views/projects/pages_domains/_certificate.html.haml13
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml9
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml7
-rw-r--r--app/views/projects/pipelines/_info.html.haml4
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml15
-rw-r--r--app/views/projects/pipelines/charts.html.haml1
-rw-r--r--app/views/projects/pipelines/index.html.haml22
-rw-r--r--app/views/projects/pipelines/show.html.haml2
-rw-r--r--app/views/projects/project_members/import.html.haml15
-rw-r--r--app/views/projects/project_members/index.html.haml46
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml5
-rw-r--r--app/views/projects/registry/repositories/index.html.haml1
-rw-r--r--app/views/projects/runners/_group_runners.html.haml6
-rw-r--r--app/views/projects/runners/_runner.html.haml4
-rw-r--r--app/views/projects/runners/_specific_runners.html.haml2
-rw-r--r--app/views/projects/services/_form.html.haml11
-rw-r--r--app/views/projects/services/prometheus/_custom_metrics.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_metrics.html.haml2
-rw-r--r--app/views/projects/settings/_general.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/packages_and_registries/show.html.haml2
-rw-r--r--app/views/projects/stage/_stage.html.haml15
-rw-r--r--app/views/projects/triggers/_trigger.html.haml2
-rw-r--r--app/views/sandbox/mermaid.html.erb3
-rw-r--r--app/views/search/results/_blob_highlight.html.haml8
-rw-r--r--app/views/shared/_default_branch_protection.html.haml4
-rw-r--r--app/views/shared/_gl_toggle.html.haml28
-rw-r--r--app/views/shared/_global_alert.html.haml20
-rw-r--r--app/views/shared/_logo_ukraine.svg5
-rw-r--r--app/views/shared/_new_project_item_select.html.haml2
-rw-r--r--app/views/shared/_service_ping_consent.html.haml1
-rw-r--r--app/views/shared/_two_factor_auth_recovery_settings_check.html.haml2
-rw-r--r--app/views/shared/access_tokens/_form.html.haml14
-rw-r--r--app/views/shared/blob/_markdown_buttons.html.haml4
-rw-r--r--app/views/shared/buttons/_project_feature_toggle.html.haml16
-rw-r--r--app/views/shared/deploy_tokens/_table.html.haml2
-rw-r--r--app/views/shared/doorkeeper/applications/_delete_form.html.haml4
-rw-r--r--app/views/shared/errors/_gitaly_unavailable.html.haml15
-rw-r--r--app/views/shared/issuable/_form.html.haml5
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml5
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml6
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml8
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar_reviewers.html.haml2
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml10
-rw-r--r--app/views/shared/issuable/form/_title.html.haml6
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml2
-rw-r--r--app/views/shared/labels/_sort_dropdown.html.haml12
-rw-r--r--app/views/shared/members/_invite_group.html.haml30
-rw-r--r--app/views/shared/members/_invite_member.html.haml28
-rw-r--r--app/views/shared/milestones/_delete_button.html.haml2
-rw-r--r--app/views/shared/milestones/_milestone_complete_alert.html.haml1
-rw-r--r--app/views/shared/milestones/_tab_loading.html.haml3
-rw-r--r--app/views/shared/nav/_sidebar_submenu.html.haml2
-rw-r--r--app/views/shared/notes/_hints.html.haml2
-rw-r--r--app/views/shared/projects/protected_branches/_update_protected_branch.html.haml5
-rw-r--r--app/views/shared/web_hooks/_hook_errors.html.haml3
-rw-r--r--app/views/shared/wikis/pages.html.haml11
-rw-r--r--app/views/users/_overview.html.haml8
-rw-r--r--app/workers/all_queues.yml38
-rw-r--r--app/workers/bulk_imports/export_request_worker.rb23
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb4
-rw-r--r--app/workers/ci/build_finished_worker.rb1
-rw-r--r--app/workers/ci/drop_pipeline_worker.rb2
-rw-r--r--app/workers/concerns/git_garbage_collect_methods.rb32
-rw-r--r--app/workers/container_expiration_policies/cleanup_container_repository_worker.rb2
-rw-r--r--app/workers/container_expiration_policy_worker.rb2
-rw-r--r--app/workers/database/batched_background_migration/ci_database_worker.rb12
-rw-r--r--app/workers/database/batched_background_migration/single_database_worker.rb83
-rw-r--r--app/workers/database/batched_background_migration_worker.rb53
-rw-r--r--app/workers/projects/git_garbage_collect_worker.rb6
-rw-r--r--app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb51
-rw-r--r--app/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker.rb18
-rw-r--r--app/workers/quality/test_data_cleanup_worker.rb33
-rw-r--r--app/workers/web_hook_worker.rb6
-rw-r--r--app/workers/wikis/git_garbage_collect_worker.rb6
1339 files changed, 16851 insertions, 9737 deletions
diff --git a/app/assets/images/aws-cloud-formation.png b/app/assets/images/aws-cloud-formation.png
deleted file mode 100644
index 1d078309d86..00000000000
--- a/app/assets/images/aws-cloud-formation.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/vulnerability/kontra-logo.svg b/app/assets/images/vulnerability/kontra-logo.svg
new file mode 100644
index 00000000000..e12e2545e77
--- /dev/null
+++ b/app/assets/images/vulnerability/kontra-logo.svg
@@ -0,0 +1 @@
+<svg enable-background="new 0 0 50 50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="10.2941" x2="50" y1="17.8247" y2="17.8247"><stop offset="0" stop-color="#f39c63"/><stop offset="1" stop-color="#ef5d4f"/></linearGradient><linearGradient id="b"><stop offset="0" stop-color="#231c4f"/><stop offset="1" stop-color="#0a0430"/></linearGradient><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="16.9118" x2="25" xlink:href="#b" y1="27.2046" y2="27.2046"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="27.9412" x2="33.0882" xlink:href="#b" y1="22.0575" y2="22.0575"/><g clip-rule="evenodd" fill-rule="evenodd"><path d="m31.94 41.71-31.94 8.41v-21.23c0-4.73 3.19-8.87 7.77-10.07l19.29-5.08c6.4-1.68 12.65 3.14 12.65 9.75v8.14c0 4.74-3.19 8.88-7.77 10.08z" fill="#2b6af9"/><path d="m50 0v21.23c0 4.73-3.19 8.87-7.77 10.07l-14.79 3.89c-8.67 2.28-17.15-4.26-17.15-13.22v-3.49c0-4.73 3.19-8.87 7.77-10.07z" fill="url(#a)"/><path d="m23.36 36.33 16.35-4.3v-8.16c0-6.83-6.46-11.81-13.06-10.07l-16.35 4.3v8.16c-.01 6.82 6.45 11.8 13.06 10.07z" fill="#fff"/><circle cx="20.96" cy="27.2" fill="url(#c)" r="4.04"/><circle cx="30.51" cy="22.06" fill="url(#d)" r="2.57"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/vulnerability/scw-logo.svg b/app/assets/images/vulnerability/scw-logo.svg
new file mode 100644
index 00000000000..6d160ddc495
--- /dev/null
+++ b/app/assets/images/vulnerability/scw-logo.svg
@@ -0,0 +1 @@
+<svg enable-background="new 0 0 0 0" viewBox="0 0 800 780" xmlns="http://www.w3.org/2000/svg"><path d="m594.4 737.87c-.75-1.93-1.86-3.7-3.34-5.29-1.48-1.6-3.29-2.86-5.44-3.8-2.15-.93-4.62-1.4-7.41-1.4s-5.26.47-7.41 1.4c-2.14.94-3.96 2.21-5.43 3.8-1.48 1.59-2.59 3.36-3.34 5.29s-1.13 3.91-1.13 5.95v.96c0 1.96.36 3.91 1.1 5.86.73 1.96 1.82 3.74 3.28 5.35s3.27 2.91 5.43 3.89c2.17.98 4.67 1.46 7.5 1.46s5.33-.49 7.5-1.46c2.16-.98 3.98-2.28 5.43-3.89 1.46-1.61 2.55-3.4 3.28-5.35s1.1-3.91 1.1-5.86v-.96c.01-2.04-.37-4.02-1.12-5.95zm-4.01 13.45c-1.15 2.1-2.78 3.77-4.86 5.02-2.09 1.25-4.52 1.89-7.32 1.89-2.79 0-5.24-.63-7.32-1.89-2.09-1.25-3.71-2.93-4.86-5.02s-1.73-4.42-1.73-6.97c0-2.63.58-4.99 1.73-7.09 1.15-2.09 2.77-3.74 4.86-4.96 2.08-1.21 4.52-1.82 7.32-1.82 2.79 0 5.23.61 7.32 1.82 2.08 1.22 3.71 2.87 4.86 4.96 1.15 2.1 1.73 4.46 1.73 7.09 0 2.56-.58 4.88-1.73 6.97z" fill="#f79200"/><path d="m584.11 746.03c1.42-1.12 2.12-2.73 2.12-4.84s-.71-3.73-2.12-4.85c-1.42-1.11-3.4-1.67-5.95-1.67h-2.55-2.37-2.12v18.66h4.49v-5.62h2.55c.09 0 .17-.01.25-.01l3.81 5.64h5.1l-4.69-6.45c.54-.25 1.04-.52 1.48-.86zm-8.5-7.6h2.85c1.1 0 1.9.23 2.43.69s.79 1.15.79 2.07-.26 1.6-.79 2.06-1.33.68-2.43.68h-2.85z" fill="#f79200"/><path d="m333.8 753.41-17.62 7.9v-88.45s76.18-50.47 93.76-64.21c46.25-36.15 101.39-92.54 101.39-155.86v-22.33l40.76-32.62h-235.91v-83.73h280.18v138.69c0 180.72-251.84 295.81-262.56 300.61zm-17.53-638.54c21.22 2.88 121.7 20.25 195.07 97.32v62.85h85.03v-94.02l-10-11.68c-104.42-122.2-259.7-137.72-266.27-138.32l-3.92-.36v84.23c.03-.01.06-.01.09-.02z" fill="#ffbe12"/><path d="m316.18 397.85h-280.18v-216.82l9.99-11.68c104.42-122.21 259.7-137.73 266.25-138.33l3.94-.36v84.23c-21.06 2.97-122.24 20.87-195.17 97.32v74.53l-36.5 27.39h231.66v83.72zm-93.77 210.81c-46.27-36.15-101.4-87.26-101.4-143.31l20.24-28.41h-105.25v28.41c0 171.17 251.84 283.27 262.56 288.07l17.63 7.9v-88.45c-.01 0-76.2-50.47-93.78-64.21z" fill="#f79200"/></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
index 1fec186f2fa..561b2617c5f 100644
--- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -1,15 +1,31 @@
<script>
-import { GlDatepicker, GlFormInput } from '@gitlab/ui';
+import { GlDatepicker, GlFormInput, GlFormGroup } from '@gitlab/ui';
+
+import { __ } from '~/locale';
export default {
name: 'ExpiresAtField',
- components: { GlDatepicker, GlFormInput },
+ i18n: {
+ label: __('Expiration date'),
+ },
+ components: {
+ GlDatepicker,
+ GlFormInput,
+ GlFormGroup,
+ MaxExpirationDateMessage: () =>
+ import('ee_component/access_tokens/components/max_expiration_date_message.vue'),
+ },
props: {
inputAttrs: {
type: Object,
required: false,
default: () => ({}),
},
+ maxDate: {
+ type: Date,
+ required: false,
+ default: () => null,
+ },
},
data() {
return {
@@ -20,13 +36,18 @@ export default {
</script>
<template>
- <gl-datepicker :target="null" :min-date="minDate">
- <gl-form-input
- v-bind="inputAttrs"
- class="datepicker gl-datepicker-input"
- autocomplete="off"
- inputmode="none"
- data-qa-selector="expiry_date_field"
- />
- </gl-datepicker>
+ <gl-form-group :label="$options.i18n.label" :label-for="inputAttrs.id">
+ <gl-datepicker :target="null" :min-date="minDate" :max-date="maxDate">
+ <gl-form-input
+ v-bind="inputAttrs"
+ class="datepicker gl-datepicker-input"
+ autocomplete="off"
+ inputmode="none"
+ data-qa-selector="expiry_date_field"
+ />
+ </gl-datepicker>
+ <template #description>
+ <max-expiration-date-message :max-date="maxDate" />
+ </template>
+ </gl-form-group>
</template>
diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue
index 755991f64e0..10d4d62d803 100644
--- a/app/assets/javascripts/access_tokens/components/tokens_app.vue
+++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue
@@ -100,6 +100,7 @@ export default {
<gl-link
:href="tokenData.resetPath"
:data-confirm="$options.i18n[tokenType].resetConfirmMessage"
+ data-confirm-btn-variant="danger"
data-method="put"
>{{ content }}</gl-link
>
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 9a1e7d877f8..c59bd445539 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -17,6 +17,7 @@ export const initExpiresAtField = () => {
}
const { expiresAt: inputAttrs } = parseRailsFormFields(el);
+ const { maxDate } = el.dataset;
return new Vue({
el,
@@ -24,6 +25,7 @@ export const initExpiresAtField = () => {
return h(ExpiresAtField, {
props: {
inputAttrs,
+ maxDate: maxDate ? new Date(maxDate) : undefined,
},
});
},
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index 74e0e1b6225..7a78ccdb0cd 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -33,7 +33,7 @@ export default class Activities {
errorCallback: () =>
createFlash({
message: s__(
- 'Activity|An error occured while retrieving activity. Reload the page to try again.',
+ 'Activity|An error occurred while retrieving activity. Reload the page to try again.',
),
parent: this.containerEl,
}),
diff --git a/app/assets/javascripts/admin/applications/components/delete_application.vue b/app/assets/javascripts/admin/applications/components/delete_application.vue
new file mode 100644
index 00000000000..77694296b0a
--- /dev/null
+++ b/app/assets/javascripts/admin/applications/components/delete_application.vue
@@ -0,0 +1,84 @@
+<script>
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ },
+ data() {
+ return {
+ name: '',
+ path: '',
+ buttons: [],
+ };
+ },
+ mounted() {
+ this.buttons = document.querySelectorAll('.js-application-delete-button');
+
+ this.buttons.forEach((button) => button.addEventListener('click', this.buttonEvent));
+ },
+ destroy() {
+ this.buttons.forEach((button) => button.removeEventListener('click', this.buttonEvent));
+ },
+ methods: {
+ buttonEvent(e) {
+ e.preventDefault();
+ this.show(e.target.dataset);
+ },
+ show(dataset) {
+ const { name, path } = dataset;
+
+ this.name = name;
+ this.path = path;
+
+ this.$refs.deleteModal.show();
+ },
+ deleteApplication() {
+ this.$refs.deleteForm.submit();
+ },
+ },
+ i18n: {
+ destroy: __('Destroy'),
+ title: __('Confirm destroy application'),
+ body: __('Are you sure that you want to destroy %{application}'),
+ },
+ modal: {
+ actionPrimary: {
+ text: __('Destroy'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
+ csrf,
+};
+</script>
+<template>
+ <gl-modal
+ ref="deleteModal"
+ :title="$options.i18n.title"
+ :action-primary="$options.modal.actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
+ modal-id="delete-application-modal"
+ size="sm"
+ @primary="deleteApplication"
+ ><gl-sprintf :message="$options.i18n.body">
+ <template #application>
+ <strong>{{ name }}</strong>
+ </template></gl-sprintf
+ >
+ <form ref="deleteForm" method="post" :action="path">
+ <input type="hidden" name="_method" value="delete" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/admin/applications/index.js b/app/assets/javascripts/admin/applications/index.js
new file mode 100644
index 00000000000..5875fd18729
--- /dev/null
+++ b/app/assets/javascripts/admin/applications/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import DeleteApplication from './components/delete_application.vue';
+
+export default () => {
+ const el = document.querySelector('.js-application-delete-modal');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(DeleteApplication);
+ },
+ });
+};
diff --git a/app/assets/javascripts/admin/topics/components/remove_avatar.vue b/app/assets/javascripts/admin/topics/components/remove_avatar.vue
new file mode 100644
index 00000000000..5e94d6185e0
--- /dev/null
+++ b/app/assets/javascripts/admin/topics/components/remove_avatar.vue
@@ -0,0 +1,67 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ components: {
+ GlButton,
+ GlModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: ['path'],
+ data() {
+ return {
+ modalId: uniqueId('remove-topic-avatar-'),
+ };
+ },
+ methods: {
+ deleteApplication() {
+ this.$refs.deleteForm.submit();
+ },
+ },
+ i18n: {
+ remove: __('Remove avatar'),
+ title: __('Confirm remove avatar'),
+ body: __('Avatar will be removed. Are you sure?'),
+ },
+ modal: {
+ actionPrimary: {
+ text: __('Remove'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
+ csrf,
+};
+</script>
+<template>
+ <div>
+ <gl-button v-gl-modal="modalId" variant="danger" category="secondary" class="gl-mt-2">{{
+ $options.i18n.remove
+ }}</gl-button>
+ <gl-modal
+ :title="$options.i18n.title"
+ :action-primary="$options.modal.actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
+ :modal-id="modalId"
+ size="sm"
+ @primary="deleteApplication"
+ >{{ $options.i18n.body }}
+ <form ref="deleteForm" method="post" :action="path">
+ <input type="hidden" name="_method" value="delete" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ </form>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js
new file mode 100644
index 00000000000..8fbcadf3369
--- /dev/null
+++ b/app/assets/javascripts/admin/topics/index.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import RemoveAvatar from './components/remove_avatar.vue';
+
+export default () => {
+ const el = document.querySelector('.js-remove-topic-avatar');
+
+ if (!el) {
+ return false;
+ }
+
+ const { path } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ path,
+ },
+ render(h) {
+ return h(RemoveAvatar);
+ },
+ });
+};
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index f5d21ece138..829174d7593 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -69,7 +69,6 @@ export default {
editButtonAttrs() {
return {
'data-testid': 'edit',
- icon: 'pencil-square',
href: this.userPaths.edit,
};
},
@@ -101,6 +100,7 @@ export default {
<gl-button
v-else
v-gl-tooltip="$options.i18n.edit"
+ icon="pencil-square"
v-bind="editButtonAttrs"
:aria-label="$options.i18n.edit"
/>
@@ -108,10 +108,9 @@ export default {
<div v-if="hasDropdownActions" class="gl-p-2">
<gl-dropdown
+ v-gl-tooltip="$options.i18n.userAdministration"
data-testid="dropdown-toggle"
- :text="$options.i18n.userAdministration"
- :text-sr-only="!showButtonLabels"
- icon="ellipsis_h"
+ icon="ellipsis_v"
data-qa-selector="user_actions_dropdown_toggle"
:data-qa-username="user.username"
no-caret
diff --git a/app/assets/javascripts/admin/users/components/user_avatar.vue b/app/assets/javascripts/admin/users/components/user_avatar.vue
index ce22595609d..dd354794cf3 100644
--- a/app/assets/javascripts/admin/users/components/user_avatar.vue
+++ b/app/assets/javascripts/admin/users/components/user_avatar.vue
@@ -27,8 +27,6 @@ export default {
return this.adminUserPath.replace('id', this.user.username);
},
adminUserMailto() {
- // NOTE: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
- // eslint-disable-next-line @gitlab/require-i18n-strings
return `mailto:${this.user.email}`;
},
userNoteShort() {
diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue
index 84c2b216859..929f5d10956 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -12,8 +12,8 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
+import { sortObjectToString } from '~/lib/utils/table_utility';
import { fetchPolicies } from '~/lib/graphql';
-import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { s__, __, n__ } from '~/locale';
import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue';
@@ -213,11 +213,8 @@ export default {
},
methods: {
fetchSortedData({ sortBy, sortDesc }) {
- const sortingDirection = sortDesc ? 'DESC' : 'ASC';
- const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
-
this.pagination = initialPaginationState;
- this.sort = `${sortingColumn}_${sortingDirection}`;
+ this.sort = sortObjectToString({ sortBy, sortDesc });
},
navigateToAlertDetails({ iid }, index, { metaKey }) {
return visitUrl(joinPaths(window.location.pathname, iid, 'details'), metaKey);
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 8c996b448aa..35fc64d43e5 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -92,6 +92,7 @@ const Api = {
groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings',
notificationSettingsPath: '/api/:version/notification_settings',
deployKeysPath: '/api/:version/deploy_keys',
+ secureFilesPath: '/api/:version/projects/:project_id/secure_files',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -957,6 +958,13 @@ const Api = {
return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...params } });
},
+ // TODO: replace this when GraphQL support has been added https://gitlab.com/gitlab-org/gitlab/-/issues/352184
+ projectSecureFiles(projectId, options = {}) {
+ const url = Api.buildUrl(this.secureFilesPath).replace(':project_id', projectId);
+
+ return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...options } });
+ },
+
async updateNotificationSettings(projectId, groupId, data = {}) {
let url = Api.buildUrl(this.notificationSettingsPath);
diff --git a/app/assets/javascripts/attention_requests/components/navigation_popover.vue b/app/assets/javascripts/attention_requests/components/navigation_popover.vue
new file mode 100644
index 00000000000..1542bc9a7e9
--- /dev/null
+++ b/app/assets/javascripts/attention_requests/components/navigation_popover.vue
@@ -0,0 +1,120 @@
+<script>
+import { GlPopover, GlSprintf, GlButton, GlLink, GlIcon } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+
+export default {
+ components: {
+ GlPopover,
+ GlSprintf,
+ GlButton,
+ GlLink,
+ GlIcon,
+ UserCalloutDismisser,
+ },
+ inject: {
+ message: {
+ default: '',
+ },
+ observerElSelector: {
+ default: '',
+ },
+ observerElToggledClass: {
+ default: '',
+ },
+ featureName: {
+ default: '',
+ },
+ popoverTarget: {
+ default: '',
+ },
+ showAttentionIcon: {
+ default: false,
+ },
+ delay: {
+ default: 0,
+ },
+ popoverCssClass: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ showPopover: false,
+ popoverPlacement: this.popoverPosition(),
+ };
+ },
+ mounted() {
+ this.observeEl = document.querySelector(this.observerElSelector);
+ this.observer = new MutationObserver(this.callback);
+ this.observer.observe(this.observeEl, {
+ attributes: true,
+ });
+ this.callback();
+
+ window.addEventListener('resize', () => {
+ this.popoverPlacement = this.popoverPosition();
+ });
+ },
+ beforeDestroy() {
+ this.observer.disconnect();
+ },
+ methods: {
+ callback() {
+ if (this.showPopover) {
+ this.$root.$emit('bv::hide::popover');
+ }
+
+ setTimeout(() => this.toggleShowPopover(), this.delay);
+ },
+ toggleShowPopover() {
+ this.showPopover = this.observeEl.classList.contains(this.observerElToggledClass);
+ },
+ getPopoverTarget() {
+ return document.querySelector(this.popoverTarget);
+ },
+ popoverPosition() {
+ if (bp.isDesktop()) {
+ return 'left';
+ }
+
+ return 'bottom';
+ },
+ },
+ docsPage: helpPagePath('development/code_review.html'),
+};
+</script>
+
+<template>
+ <user-callout-dismisser :feature-name="featureName">
+ <template #default="{ shouldShowCallout, dismiss }">
+ <gl-popover
+ v-if="shouldShowCallout"
+ :show-close-button="false"
+ :target="() => getPopoverTarget()"
+ :show="showPopover"
+ :delay="0"
+ triggers="manual"
+ :placement="popoverPlacement"
+ boundary="window"
+ no-fade
+ :css-classes="[popoverCssClass]"
+ >
+ <p v-for="(m, index) in message" :key="index" class="gl-mb-5">
+ <gl-sprintf :message="m">
+ <template #strong="{ content }">
+ <strong><gl-icon v-if="showAttentionIcon" name="attention" /> {{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-button size="small" variant="confirm" class="gl-mr-5" @click.prevent.stop="dismiss">
+ {{ __('Got it!') }}
+ </gl-button>
+ <gl-link :href="$options.docsPage" target="_blank">{{ __('Learn more') }}</gl-link>
+ </div>
+ </gl-popover>
+ </template>
+ </user-callout-dismisser>
+</template>
diff --git a/app/assets/javascripts/attention_requests/index.js b/app/assets/javascripts/attention_requests/index.js
new file mode 100644
index 00000000000..2a142ab46e5
--- /dev/null
+++ b/app/assets/javascripts/attention_requests/index.js
@@ -0,0 +1,73 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { __ } from '~/locale';
+import createDefaultClient from '~/lib/graphql';
+import NavigationPopover from './components/navigation_popover.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const initTopNavPopover = () => {
+ const el = document.getElementById('js-need-attention-nav-onboarding');
+
+ if (!el) return;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ observerElSelector: '.user-counter.dropdown',
+ observerElToggledClass: 'show',
+ message: [
+ __(
+ '%{strongStart}Need your attention%{strongEnd} are the merge requests that need your help to move forward, as an assignee or reviewer.',
+ ),
+ ],
+ featureName: 'attention_requests_top_nav',
+ popoverTarget: '#js-need-attention-nav',
+ },
+ render(h) {
+ return h(NavigationPopover);
+ },
+ });
+};
+
+export const initSideNavPopover = () => {
+ const el = document.getElementById('js-need-attention-sidebar-onboarding');
+
+ if (!el) return;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ observerElSelector: '.js-right-sidebar',
+ observerElToggledClass: 'right-sidebar-expanded',
+ message: [
+ __(
+ 'To ask someone to look at a merge request, select %{strongStart}Request attention%{strongEnd}. Select again to remove the request.',
+ ),
+ __(
+ 'Some actions remove attention requests, like a reviewer approving or anyone merging the merge request.',
+ ),
+ ],
+ featureName: 'attention_requests_side_nav',
+ popoverTarget: '.js-attention-request-toggle',
+ showAttentionIcon: true,
+ delay: 500,
+ popoverCssClass: 'attention-request-sidebar-popover',
+ },
+ render(h) {
+ return h(NavigationPopover);
+ },
+ });
+};
+
+export default () => {
+ initTopNavPopover();
+};
diff --git a/app/assets/javascripts/authentication/webauthn/util.js b/app/assets/javascripts/authentication/webauthn/util.js
index eeda2bfaeaf..2a0740cf488 100644
--- a/app/assets/javascripts/authentication/webauthn/util.js
+++ b/app/assets/javascripts/authentication/webauthn/util.js
@@ -46,6 +46,17 @@ export const bufferToBase64 = (input) => {
};
/**
+ * Return a URL-safe base64 string.
+ *
+ * RFC: https://datatracker.ietf.org/doc/html/rfc4648#section-5
+ * @param {String} base64Str
+ * @returns {String}
+ */
+export const base64ToBase64Url = (base64Str) => {
+ return base64Str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+};
+
+/**
* Returns a copy of the given object with the id property converted to buffer
*
* @param {Object} param
diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
index b512e4dbc8b..165031c3e7d 100644
--- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js
+++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
@@ -48,54 +48,48 @@ import Video from './nodes/video';
// from GFM should have a node or mark here.
// The GFM-to-HTML-to-GFM cycle is tested in spec/features/markdown/copy_as_gfm_spec.rb.
-export default [
- new Doc(),
- new Paragraph(),
- new Text(),
+export default {
+ nodes: [
+ Doc(),
+ Paragraph(),
+ Text(),
- new Blockquote(),
- new CodeBlock(),
- new HardBreak(),
- new Heading({ maxLevel: 6 }),
- new HorizontalRule(),
- new Image(),
+ Blockquote(),
+ CodeBlock(),
+ HardBreak(),
+ Heading(),
+ HorizontalRule(),
+ Image(),
- new Table(),
- new TableHead(),
- new TableBody(),
- new TableHeaderRow(),
- new TableRow(),
- new TableCell(),
+ Table(),
+ TableHead(),
+ TableBody(),
+ TableHeaderRow(),
+ TableRow(),
+ TableCell(),
- new Emoji(),
- new Reference(),
+ Emoji(),
+ Reference(),
- new TableOfContents(),
- new Video(),
- new Audio(),
+ TableOfContents(),
+ Video(),
+ Audio(),
- new BulletList(),
- new OrderedList(),
- new ListItem(),
+ BulletList(),
+ OrderedList(),
+ ListItem(),
- new DescriptionList(),
- new DescriptionTerm(),
- new DescriptionDetails(),
+ DescriptionList(),
+ DescriptionTerm(),
+ DescriptionDetails(),
- new TaskList(),
- new OrderedTaskList(),
- new TaskListItem(),
+ TaskList(),
+ OrderedTaskList(),
+ TaskListItem(),
- new Summary(),
- new Details(),
+ Summary(),
+ Details(),
+ ],
- new Bold(),
- new Italic(),
- new Strike(),
- new InlineDiff(),
-
- new Link(),
- new Code(),
- new MathMark(),
- new InlineHTML(),
-];
+ marks: [Bold(), Italic(), Strike(), InlineDiff(), Link(), Code(), MathMark(), InlineHTML()],
+};
diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js
index 89e373220af..dd730947a5f 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/bold.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/bold.js
@@ -1,11 +1,17 @@
-/* eslint-disable class-methods-use-this */
-
-import { Bold as BaseBold } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Bold extends BaseBold {
- get toMarkdown() {
- return defaultMarkdownSerializer.marks.strong;
- }
-}
+export default () => {
+ return {
+ name: 'bold',
+ schema: {
+ parseDOM: [
+ {
+ tag: 'strong',
+ },
+ ],
+ toDOM: () => ['strong', 0],
+ },
+ toMarkdown: defaultMarkdownSerializer.marks.strong,
+ };
+};
diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js
index 68368dec676..ea5af8b4a1f 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/code.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/code.js
@@ -1,11 +1,12 @@
-/* eslint-disable class-methods-use-this */
-
-import { Code as BaseCode } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Code extends BaseCode {
- get toMarkdown() {
- return defaultMarkdownSerializer.marks.code;
- }
-}
+export default () => ({
+ name: 'code',
+ schema: {
+ excludes: '_',
+ parseDOM: [{ tag: 'code' }],
+ toDOM: () => ['code', 0],
+ },
+ toMarkdown: defaultMarkdownSerializer.marks.code,
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
index 7f1506cd5d9..69d345c81e4 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
@@ -1,41 +1,29 @@
-/* eslint-disable class-methods-use-this */
-
-import { Mark } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::InlineDiffFilter
-export default class InlineDiff extends Mark {
- get name() {
- return 'inline_diff';
- }
-
- get schema() {
- return {
- attrs: {
- addition: {
- default: true,
- },
+export default () => ({
+ name: 'inline_diff',
+ schema: {
+ attrs: {
+ addition: {
+ default: true,
},
- parseDOM: [
- { tag: 'span.idiff.addition', attrs: { addition: true } },
- { tag: 'span.idiff.deletion', attrs: { addition: false } },
- ],
- toDOM: (node) => [
- 'span',
- { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` },
- 0,
- ],
- };
- }
-
- get toMarkdown() {
- return {
- mixable: true,
- open(state, mark) {
- return mark.attrs.addition ? '{+' : '{-';
- },
- close(state, mark) {
- return mark.attrs.addition ? '+}' : '-}';
- },
- };
- }
-}
+ },
+ parseDOM: [
+ { tag: 'span.idiff.addition', attrs: { addition: true } },
+ { tag: 'span.idiff.deletion', attrs: { addition: false } },
+ ],
+ toDOM: (node) => [
+ 'span',
+ { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` },
+ 0,
+ ],
+ },
+ toMarkdown: {
+ mixable: true,
+ open(_, mark) {
+ return mark.attrs.addition ? '{+' : '{-';
+ },
+ close(_, mark) {
+ return mark.attrs.addition ? '+}' : '-}';
+ },
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
index e957f81b774..4520598e0ab 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
@@ -1,46 +1,35 @@
-/* eslint-disable class-methods-use-this */
-
import { escape } from 'lodash';
-import { Mark } from 'tiptap';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class InlineHTML extends Mark {
- get name() {
- return 'inline_html';
- }
-
- get schema() {
- return {
- excludes: '',
- attrs: {
- tag: {},
- title: { default: null },
- },
- parseDOM: [
- {
- tag: 'sup, sub, kbd, q, samp, var',
- getAttrs: (el) => ({ tag: el.nodeName.toLowerCase() }),
- },
- {
- tag: 'abbr',
- getAttrs: (el) => ({ tag: 'abbr', title: el.getAttribute('title') }),
- },
- ],
- toDOM: (node) => [node.attrs.tag, { title: node.attrs.title }, 0],
- };
- }
-
- get toMarkdown() {
- return {
- mixable: true,
- open(state, mark) {
- return `<${mark.attrs.tag}${
- mark.attrs.title ? ` title="${state.esc(escape(mark.attrs.title))}"` : ''
- }>`;
+export default () => ({
+ name: 'inline_html',
+ schema: {
+ excludes: '',
+ attrs: {
+ tag: {},
+ title: { default: null },
+ },
+ parseDOM: [
+ {
+ tag: 'sup, sub, kbd, q, samp, var',
+ getAttrs: (el) => ({ tag: el.nodeName.toLowerCase() }),
},
- close(state, mark) {
- return `</${mark.attrs.tag}>`;
+ {
+ tag: 'abbr',
+ getAttrs: (el) => ({ tag: 'abbr', title: el.getAttribute('title') }),
},
- };
- }
-}
+ ],
+ toDOM: (node) => [node.attrs.tag, { title: node.attrs.title }, 0],
+ },
+ toMarkdown: {
+ mixable: true,
+ open(state, mark) {
+ return `<${mark.attrs.tag}${
+ mark.attrs.title ? ` title="${state.esc(escape(mark.attrs.title))}"` : ''
+ }>`;
+ },
+ close(_, mark) {
+ return `</${mark.attrs.tag}>`;
+ },
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js
index 7dc86102f18..3ec8f0071e9 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/italic.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/italic.js
@@ -1,11 +1,11 @@
-/* eslint-disable class-methods-use-this */
-
-import { Italic as BaseItalic } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Italic extends BaseItalic {
- get toMarkdown() {
- return defaultMarkdownSerializer.marks.em;
- }
-}
+export default () => ({
+ name: 'italic',
+ schema: {
+ parseDOM: [{ tag: 'em' }],
+ toDOM: () => ['em', 0],
+ },
+ toMarkdown: defaultMarkdownSerializer.marks.em,
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js
index b5e09017d83..977453fee01 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/link.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/link.js
@@ -1,21 +1,47 @@
-/* eslint-disable class-methods-use-this */
-
-import { Link as BaseLink } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Link extends BaseLink {
- get toMarkdown() {
- return {
- mixable: true,
- open(state, mark, parent, index) {
- const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index);
- return open === '<' ? '' : open;
+export default () => ({
+ name: 'link',
+ schema: {
+ attrs: {
+ href: {
+ default: null,
+ },
+ target: {
+ default: null,
+ },
+ },
+ inclusive: false,
+ parseDOM: [
+ {
+ tag: 'a[href]',
+ getAttrs: (dom) => ({
+ href: dom.getAttribute('href'),
+ target: dom.getAttribute('target'),
+ }),
},
- close(state, mark, parent, index) {
- const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index);
- return close === '>' ? '' : close;
+ ],
+ toDOM: (node) => [
+ 'a',
+ {
+ ...node.attrs,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ rel: 'noopener noreferrer nofollow',
+ target: node.attrs.target,
},
- };
- }
-}
+ 0,
+ ],
+ },
+ toMarkdown: {
+ mixable: true,
+ open(state, mark, parent, index) {
+ const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index);
+ return open === '<' ? '' : open;
+ },
+ close(state, mark, parent, index) {
+ const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index);
+ return close === '>' ? '' : close;
+ },
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js
index ca25ff7d07d..a50a649b6eb 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/math.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/math.js
@@ -1,42 +1,31 @@
-/* eslint-disable class-methods-use-this */
-
-import { Mark } from 'tiptap';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter
-export default class MathMark extends Mark {
- get name() {
- return 'math';
- }
-
- get schema() {
- return {
- parseDOM: [
- // Matches HTML generated by Banzai::Filter::MathFilter
- {
- tag: 'code.code.math[data-math-style=inline]',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- },
- // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
- {
- tag: 'span.katex',
- contentElement: 'annotation[encoding="application/x-tex"]',
- },
- ],
- toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0],
- };
- }
-
- get toMarkdown() {
- return {
- escape: false,
- open(state, mark, parent, index) {
- return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`;
+export default () => ({
+ name: 'math',
+ schema: {
+ parseDOM: [
+ // Matches HTML generated by Banzai::Filter::MathFilter
+ {
+ tag: 'code.code.math[data-math-style=inline]',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
},
- close(state, mark, parent, index) {
- return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`;
+ // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
+ {
+ tag: 'span.katex',
+ contentElement: 'annotation[encoding="application/x-tex"]',
},
- };
- }
-}
+ ],
+ toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0],
+ },
+ toMarkdown: {
+ escape: false,
+ open(state, mark, parent, index) {
+ return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`;
+ },
+ close(state, mark, parent, index) {
+ return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`;
+ },
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js
index c2951a40a4b..967c0a120cd 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/strike.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js
@@ -1,15 +1,18 @@
-/* eslint-disable class-methods-use-this */
-
-import { Strike as BaseStrike } from 'tiptap-extensions';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Strike extends BaseStrike {
- get toMarkdown() {
- return {
- open: '~~',
- close: '~~',
- mixable: true,
- expelEnclosingWhitespace: true,
- };
- }
-}
+export default () => ({
+ name: 'strike',
+ schema: {
+ parseDOM: [
+ {
+ tag: 'del',
+ },
+ ],
+ toDOM: () => ['s', 0],
+ },
+ toMarkdown: {
+ open: '~~',
+ close: '~~',
+ mixable: true,
+ expelEnclosingWhitespace: true,
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/audio.js b/app/assets/javascripts/behaviors/markdown/nodes/audio.js
index 146349b118c..97ab86c6d23 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/audio.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/audio.js
@@ -1,9 +1,4 @@
-import Playable from './playable';
+import playable from './playable';
// Transforms generated HTML back to GFM for Banzai::Filter::AudioLinkFilter
-export default class Audio extends Playable {
- constructor() {
- super();
- this.mediaType = 'audio';
- }
-}
+export default () => playable({ mediaType: 'audio' });
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
index 8b14a04e2fe..6a4552d47e4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
@@ -1,13 +1,19 @@
-/* eslint-disable class-methods-use-this */
-
-import { Blockquote as BaseBlockquote } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Blockquote extends BaseBlockquote {
+export default () => ({
+ name: 'blockquote',
+ schema: {
+ content: 'block*',
+ group: 'block',
+ defining: true,
+ draggable: false,
+ parseDOM: [{ tag: 'blockquote' }],
+ toDOM: () => ['blockquote', 0],
+ },
toMarkdown(state, node) {
if (!node.childCount) return;
defaultMarkdownSerializer.nodes.blockquote(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
index ef1eafaa419..95cd3605da5 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
@@ -1,11 +1,15 @@
-/* eslint-disable class-methods-use-this */
-
-import { BulletList as BaseBulletList } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class BulletList extends BaseBulletList {
+export default () => ({
+ name: 'bullet_list',
+ schema: {
+ content: 'list_item+',
+ group: 'block',
+ parseDOM: [{ tag: 'ul' }],
+ toDOM: () => ['ul', 0],
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.bullet_list(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
index cd90d67c60d..0ff59779e7d 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
@@ -1,7 +1,3 @@
-/* eslint-disable class-methods-use-this */
-
-import { CodeBlock as BaseCodeBlock } from 'tiptap-extensions';
-
const PLAINTEXT_LANG = 'plaintext';
// Transforms generated HTML back to GFM for:
@@ -9,68 +5,67 @@ const PLAINTEXT_LANG = 'plaintext';
// - Banzai::Filter::MathFilter
// - Banzai::Filter::MermaidFilter
// - Banzai::Filter::SuggestionFilter
-export default class CodeBlock extends BaseCodeBlock {
- get schema() {
- return {
- content: 'text*',
- marks: '',
- group: 'block',
- code: true,
- defining: true,
- attrs: {
- lang: { default: PLAINTEXT_LANG },
- },
- parseDOM: [
- // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, or Banzai::Filter::SuggestionFilter
- {
- tag: 'pre.code.highlight',
- preserveWhitespace: 'full',
- getAttrs: (el) => {
- const lang = el.getAttribute('lang');
- if (!lang || lang === '') return {};
+export default () => ({
+ name: 'code_block',
+ schema: {
+ content: 'text*',
+ marks: '',
+ group: 'block',
+ code: true,
+ defining: true,
+ attrs: {
+ lang: { default: PLAINTEXT_LANG },
+ },
+ parseDOM: [
+ // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, or Banzai::Filter::SuggestionFilter
+ {
+ tag: 'pre.code.highlight',
+ preserveWhitespace: 'full',
+ getAttrs: (el) => {
+ const lang = el.getAttribute('lang');
+ if (!lang || lang === '') return {};
- return { lang };
- },
- },
- // Matches HTML generated by Banzai::Filter::MathFilter,
- // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
- {
- tag: 'span.katex-display',
- preserveWhitespace: 'full',
- contentElement: 'annotation[encoding="application/x-tex"]',
- attrs: { lang: 'math' },
- },
- // Matches HTML generated by Banzai::Filter::MermaidFilter,
- // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js
- {
- tag: 'svg.mermaid',
- preserveWhitespace: 'full',
- contentElement: 'text.source',
- attrs: { lang: 'mermaid' },
- },
- // Matches HTML generated by Banzai::Filter::SuggestionFilter,
- // after being transformed by app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
- {
- tag: '.md-suggestion',
- skip: true,
- },
- {
- tag: '.md-suggestion-header',
- ignore: true,
+ return { lang };
},
- {
- tag: '.md-suggestion-diff',
- preserveWhitespace: 'full',
- getContent: (el, schema) =>
- [...el.querySelectorAll('.line_content.new span')].map((span) =>
- schema.text(span.innerText),
- ),
- attrs: { lang: 'suggestion' },
- },
- ],
- toDOM: (node) => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]],
- };
- }
+ },
+ // Matches HTML generated by Banzai::Filter::MathFilter,
+ // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
+ {
+ tag: 'span.katex-display',
+ preserveWhitespace: 'full',
+ contentElement: 'annotation[encoding="application/x-tex"]',
+ attrs: { lang: 'math' },
+ },
+ // Matches HTML generated by Banzai::Filter::MermaidFilter,
+ // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js
+ {
+ tag: 'svg.mermaid',
+ preserveWhitespace: 'full',
+ contentElement: 'text.source',
+ attrs: { lang: 'mermaid' },
+ },
+ // Matches HTML generated by Banzai::Filter::SuggestionFilter,
+ // after being transformed by app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+ {
+ tag: '.md-suggestion',
+ skip: true,
+ },
+ {
+ tag: '.md-suggestion-header',
+ ignore: true,
+ },
+ {
+ tag: '.md-suggestion-diff',
+ preserveWhitespace: 'full',
+ getContent: (el, schema) =>
+ [...el.querySelectorAll('.line_content.new span')].map((span) =>
+ schema.text(span.innerText),
+ ),
+ attrs: { lang: 'suggestion' },
+ },
+ ],
+ toDOM: (node) => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]],
+ },
toMarkdown(state, node) {
if (!node.childCount) return;
@@ -95,5 +90,5 @@ export default class CodeBlock extends BaseCodeBlock {
state.write('```');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js
index a4451d8ce8d..20760286045 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js
@@ -1,22 +1,14 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class DescriptionDetails extends Node {
- get name() {
- return 'description_details';
- }
+export default () => ({
+ name: 'description_details',
- get schema() {
- return {
- content: 'text*',
- marks: '',
- defining: true,
- parseDOM: [{ tag: 'dd' }],
- toDOM: () => ['dd', 0],
- };
- }
+ schema: {
+ content: 'text*',
+ marks: '',
+ defining: true,
+ parseDOM: [{ tag: 'dd' }],
+ toDOM: () => ['dd', 0],
+ },
toMarkdown(state, node) {
state.flushClose(1);
@@ -24,5 +16,5 @@ export default class DescriptionDetails extends Node {
state.text(node.textContent, false);
state.write('</dd>');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js
index 6aa1aca29d7..c5305c48423 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js
@@ -1,21 +1,12 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class DescriptionList extends Node {
- get name() {
- return 'description_list';
- }
-
- get schema() {
- return {
- content: '(description_term+ description_details+)+',
- group: 'block',
- parseDOM: [{ tag: 'dl' }],
- toDOM: () => ['dl', 0],
- };
- }
+export default () => ({
+ name: 'description_list',
+ schema: {
+ content: '(description_term+ description_details+)+',
+ group: 'block',
+ parseDOM: [{ tag: 'dl' }],
+ toDOM: () => ['dl', 0],
+ },
toMarkdown(state, node) {
state.write('<dl>\n');
@@ -24,5 +15,5 @@ export default class DescriptionList extends Node {
state.ensureNewLine();
state.write('</dl>');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js
index 89057ec6444..f78f7f13fc4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js
@@ -1,28 +1,18 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class DescriptionTerm extends Node {
- get name() {
- return 'description_term';
- }
-
- get schema() {
- return {
- content: 'text*',
- marks: '',
- defining: true,
- parseDOM: [{ tag: 'dt' }],
- toDOM: () => ['dt', 0],
- };
- }
-
+export default () => ({
+ name: 'description_term',
+ schema: {
+ content: 'text*',
+ marks: '',
+ defining: true,
+ parseDOM: [{ tag: 'dt' }],
+ toDOM: () => ['dt', 0],
+ },
toMarkdown(state, node) {
state.flushClose(state.closed && state.closed.type === node.type ? 1 : 2);
state.write('<dt>');
state.text(node.textContent, false);
state.write('</dt>');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/details.js b/app/assets/javascripts/behaviors/markdown/nodes/details.js
index 1c40dbb8168..9fb0d60b93a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/details.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/details.js
@@ -1,22 +1,12 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Details extends Node {
- get name() {
- return 'details';
- }
-
- get schema() {
- return {
- content: 'summary block*',
- group: 'block',
- parseDOM: [{ tag: 'details' }],
- toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0],
- };
- }
-
+export default () => ({
+ name: 'details',
+ schema: {
+ content: 'summary block*',
+ group: 'block',
+ parseDOM: [{ tag: 'details' }],
+ toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0],
+ },
toMarkdown(state, node) {
state.write('<details>\n');
state.renderContent(node);
@@ -24,5 +14,5 @@ export default class Details extends Node {
state.ensureNewLine();
state.write('</details>');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/doc.js b/app/assets/javascripts/behaviors/markdown/nodes/doc.js
index 88b16fd85da..3101e6e0e3a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/doc.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/doc.js
@@ -1,15 +1,6 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
-export default class Doc extends Node {
- get name() {
- return 'doc';
- }
-
- get schema() {
- return {
- content: 'block+',
- };
- }
-}
+export default () => ({
+ name: 'doc',
+ schema: {
+ content: 'block+',
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
index 9d0890aa1b4..086c277bad4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
@@ -1,53 +1,43 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::EmojiFilter
-export default class Emoji extends Node {
- get name() {
- return 'emoji';
- }
-
- get schema() {
- return {
- inline: true,
- group: 'inline',
- attrs: {
- name: {},
- title: {},
- moji: {},
+export default () => ({
+ name: 'emoji',
+ schema: {
+ inline: true,
+ group: 'inline',
+ attrs: {
+ name: {},
+ title: {},
+ moji: {},
+ },
+ parseDOM: [
+ {
+ tag: 'gl-emoji',
+ getAttrs: (el) => ({
+ name: el.dataset.name,
+ title: el.getAttribute('title'),
+ moji: el.textContent,
+ }),
},
- parseDOM: [
- {
- tag: 'gl-emoji',
- getAttrs: (el) => ({
- name: el.dataset.name,
- title: el.getAttribute('title'),
- moji: el.textContent,
- }),
- },
- {
- tag: 'img.emoji',
- getAttrs: (el) => {
- const name = el.getAttribute('title').replace(/^:|:$/g, '');
+ {
+ tag: 'img.emoji',
+ getAttrs: (el) => {
+ const name = el.getAttribute('title').replace(/^:|:$/g, '');
- return {
- name,
- title: name,
- moji: name,
- };
- },
+ return {
+ name,
+ title: name,
+ moji: name,
+ };
},
- ],
- toDOM: (node) => [
- 'gl-emoji',
- { 'data-name': node.attrs.name, title: node.attrs.title },
- node.attrs.moji,
- ],
- };
- }
-
+ },
+ ],
+ toDOM: (node) => [
+ 'gl-emoji',
+ { 'data-name': node.attrs.name, title: node.attrs.title },
+ node.attrs.moji,
+ ],
+ },
toMarkdown(state, node) {
state.write(`:${node.attrs.name}:`);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js
index 59e5d8ab3e2..1668af9c3f4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js
@@ -1,10 +1,14 @@
-/* eslint-disable class-methods-use-this */
-
-import { HardBreak as BaseHardBreak } from 'tiptap-extensions';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class HardBreak extends BaseHardBreak {
+export default () => ({
+ name: 'hard_break',
+ schema: {
+ inline: true,
+ group: 'inline',
+ selectable: false,
+ parseDOM: [{ tag: 'br' }],
+ toDOM: () => ['br'],
+ },
toMarkdown(state) {
if (!state.atBlank()) state.write(' \n');
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js
index 29967e61ffa..21b4ec69b70 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/heading.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/heading.js
@@ -1,13 +1,27 @@
-/* eslint-disable class-methods-use-this */
-
-import { Heading as BaseHeading } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Heading extends BaseHeading {
+export default ({ levels = [1, 2, 3, 4, 5, 6] } = {}) => ({
+ name: 'heading',
+ schema: {
+ attrs: {
+ level: {
+ default: 1,
+ },
+ },
+ content: 'inline*',
+ group: 'block',
+ defining: true,
+ draggable: false,
+ parseDOM: levels.map((level) => ({
+ tag: `h${level}`,
+ attrs: { level },
+ })),
+ toDOM: (node) => [`h${node.attrs.level}`, 0],
+ },
toMarkdown(state, node) {
if (!node.childCount) return;
defaultMarkdownSerializer.nodes.heading(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
index ee3aa145dc3..2d7074e567f 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
@@ -1,11 +1,14 @@
-/* eslint-disable class-methods-use-this */
-
-import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class HorizontalRule extends BaseHorizontalRule {
+export default () => ({
+ name: 'horizontal_rule',
+ schema: {
+ group: 'block',
+ parseDOM: [{ tag: 'hr' }],
+ toDOM: () => ['hr'],
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.horizontal_rule(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js
index 16647d2f96e..370cc347a05 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/image.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js
@@ -1,53 +1,48 @@
-/* eslint-disable class-methods-use-this */
-
-import { Image as BaseImage } from 'tiptap-extensions';
import { placeholderImage } from '~/lazy_loader';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
-export default class Image extends BaseImage {
- get schema() {
- return {
- attrs: {
- src: {},
- alt: {
- default: null,
- },
- title: {
- default: null,
- },
+export default () => ({
+ name: 'image',
+ schema: {
+ attrs: {
+ src: {},
+ alt: {
+ default: null,
},
- group: 'inline',
- inline: true,
- draggable: true,
- parseDOM: [
- // Matches HTML generated by Banzai::Filter::ImageLinkFilter
- {
- tag: 'a.no-attachment-icon',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- skip: true,
- },
- // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter
- {
- tag: 'img[src]:not(.emoji)',
- getAttrs: (el) => {
- const imageSrc = el.src;
- const imageUrl =
- imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || '';
+ title: {
+ default: null,
+ },
+ },
+ group: 'inline',
+ inline: true,
+ draggable: true,
+ parseDOM: [
+ // Matches HTML generated by Banzai::Filter::ImageLinkFilter
+ {
+ tag: 'a.no-attachment-icon',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ skip: true,
+ },
+ // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter
+ {
+ tag: 'img[src]:not(.emoji)',
+ getAttrs: (el) => {
+ const imageSrc = el.src;
+ const imageUrl =
+ imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || '';
- return {
- src: imageUrl,
- title: el.getAttribute('title'),
- alt: el.getAttribute('alt'),
- };
- },
+ return {
+ src: imageUrl,
+ title: el.getAttribute('title'),
+ alt: el.getAttribute('alt'),
+ };
},
- ],
- toDOM: (node) => ['img', node.attrs],
- };
- }
-
+ },
+ ],
+ toDOM: (node) => ['img', node.attrs],
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.image(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
index 7204b7c09ba..97c1f07427d 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
@@ -1,11 +1,16 @@
-/* eslint-disable class-methods-use-this */
-
-import { ListItem as BaseListItem } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class ListItem extends BaseListItem {
+export default () => ({
+ name: 'list_item',
+ schema: {
+ content: 'paragraph block*',
+ defining: true,
+ draggable: false,
+ parseDOM: [{ tag: 'li' }],
+ toDOM: () => ['li', 0],
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.list_item(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js
index 4c1542d14ea..f2f3eff266a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js
@@ -1,10 +1,25 @@
-/* eslint-disable class-methods-use-this */
-
-import { OrderedList as BaseOrderedList } from 'tiptap-extensions';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class OrderedList extends BaseOrderedList {
+export default () => ({
+ name: 'ordered_list',
+ schema: {
+ attrs: {
+ order: {
+ default: 1,
+ },
+ },
+ content: 'list_item+',
+ group: 'block',
+ parseDOM: [
+ {
+ tag: 'ol',
+ getAttrs: (dom) => ({
+ order: dom.hasAttribute('start') ? dom.getAttribute('start') + 1 : 1,
+ }),
+ },
+ ],
+ toDOM: (node) => (node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0]),
+ },
toMarkdown(state, node) {
state.renderList(node, ' ', () => '1. ');
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
index a28d7be3758..53a6a0d9e07 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
@@ -1,29 +1,21 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
-export default class OrderedTaskList extends Node {
- get name() {
- return 'ordered_task_list';
- }
-
- get schema() {
- return {
- group: 'block',
- content: '(task_list_item|list_item)+',
- parseDOM: [
- {
- priority: HIGHER_PARSE_RULE_PRIORITY,
- tag: 'ol.task-list',
- },
- ],
- toDOM: () => ['ol', { class: 'task-list' }, 0],
- };
- }
+export default () => ({
+ name: 'ordered_task_list',
+ schema: {
+ group: 'block',
+ content: '(task_list_item|list_item)+',
+ parseDOM: [
+ {
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ tag: 'ol.task-list',
+ },
+ ],
+ toDOM: () => ['ol', { class: 'task-list' }, 0],
+ },
toMarkdown(state, node) {
state.renderList(node, ' ', () => '1. ');
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
index 5fd098cd46f..310feebb390 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
@@ -1,24 +1,15 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Paragraph extends Node {
- get name() {
- return 'paragraph';
- }
-
- get schema() {
- return {
- content: 'inline*',
- group: 'block',
- parseDOM: [{ tag: 'p' }],
- toDOM: () => ['p', 0],
- };
- }
-
+export default () => ({
+ name: 'paragraph',
+ schema: {
+ content: 'inline*',
+ group: 'block',
+ parseDOM: [{ tag: 'p' }],
+ toDOM: () => ['p', 0],
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.paragraph(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/playable.js b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
index 90cbaf9ef4c..7559c2a6a8a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/playable.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
@@ -1,7 +1,3 @@
-/* eslint-disable class-methods-use-this */
-/* eslint-disable @gitlab/require-i18n-strings */
-
-import { Node } from 'tiptap';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
/**
@@ -10,62 +6,51 @@ import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer
* the `mediaType` property in their constructors.
* @abstract
*/
-export default class Playable extends Node {
- constructor() {
- super();
- this.mediaType = '';
- this.extraElementAttrs = {};
- }
-
- get name() {
- return this.mediaType;
- }
-
- get schema() {
- const attrs = {
- src: {},
- alt: {
- default: null,
- },
- };
-
- const parseDOM = [
+export default ({ mediaType, extraElementAttrs = {} }) => {
+ const attrs = {
+ src: {},
+ alt: {
+ default: null,
+ },
+ };
+ const parseDOM = [
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ tag: `.${mediaType}-container`,
+ getAttrs: (el) => ({
+ src: el.querySelector(mediaType).src,
+ alt: el.querySelector(mediaType).dataset.title,
+ }),
+ },
+ ];
+ const toDOM = (node) => [
+ 'span',
+ { class: `media-container ${mediaType}-container` },
+ [
+ mediaType,
{
- tag: `.${this.mediaType}-container`,
- getAttrs: (el) => ({
- src: el.querySelector(this.mediaType).src,
- alt: el.querySelector(this.mediaType).dataset.title,
- }),
+ src: node.attrs.src,
+ controls: true,
+ 'data-setup': '{}',
+ 'data-title': node.attrs.alt,
+ ...extraElementAttrs,
},
- ];
-
- const toDOM = (node) => [
- '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],
- ];
+ ],
+ ['a', { href: node.attrs.src }, node.attrs.alt],
+ ];
- return {
+ return {
+ name: mediaType,
+ schema: {
attrs,
group: 'inline',
inline: true,
draggable: true,
parseDOM,
toDOM,
- };
- }
-
- toMarkdown(state, node) {
- defaultMarkdownSerializer.nodes.image(state, node);
- }
-}
+ },
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.image(state, node);
+ },
+ };
+};
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/reference.js b/app/assets/javascripts/behaviors/markdown/nodes/reference.js
index dd82ea58ea5..9ae6ab07004 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/reference.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/reference.js
@@ -1,53 +1,44 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses
-export default class Reference extends Node {
- get name() {
- return 'reference';
- }
-
- get schema() {
- return {
- inline: true,
- group: 'inline',
- atom: true,
- attrs: {
- className: {},
- referenceType: {},
- originalText: { default: null },
- href: {},
- text: {},
+export default () => ({
+ name: 'reference',
+ schema: {
+ inline: true,
+ group: 'inline',
+ atom: true,
+ attrs: {
+ className: {},
+ referenceType: {},
+ originalText: { default: null },
+ href: {},
+ text: {},
+ },
+ parseDOM: [
+ {
+ tag: 'a.gfm:not([data-link=true])',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ getAttrs: (el) => ({
+ className: el.className,
+ referenceType: el.dataset.referenceType,
+ originalText: el.dataset.original,
+ href: el.getAttribute('href'),
+ text: el.textContent,
+ }),
},
- parseDOM: [
- {
- tag: 'a.gfm:not([data-link=true])',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- getAttrs: (el) => ({
- className: el.className,
- referenceType: el.dataset.referenceType,
- originalText: el.dataset.original,
- href: el.getAttribute('href'),
- text: el.textContent,
- }),
- },
- ],
- toDOM: (node) => [
- 'a',
- {
- class: node.attrs.className,
- href: node.attrs.href,
- 'data-reference-type': node.attrs.referenceType,
- 'data-original': node.attrs.originalText,
- },
- node.attrs.text,
- ],
- };
- }
-
+ ],
+ toDOM: (node) => [
+ 'a',
+ {
+ class: node.attrs.className,
+ href: node.attrs.href,
+ 'data-reference-type': node.attrs.referenceType,
+ 'data-original': node.attrs.originalText,
+ },
+ node.attrs.text,
+ ],
+ },
toMarkdown(state, node) {
state.write(node.attrs.originalText || node.attrs.text);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/summary.js b/app/assets/javascripts/behaviors/markdown/nodes/summary.js
index 2e36e316d71..eb91b3c981e 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/summary.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/summary.js
@@ -1,27 +1,17 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Summary extends Node {
- get name() {
- return 'summary';
- }
-
- get schema() {
- return {
- content: 'text*',
- marks: '',
- defining: true,
- parseDOM: [{ tag: 'summary' }],
- toDOM: () => ['summary', 0],
- };
- }
-
+export default () => ({
+ name: 'summary',
+ schema: {
+ content: 'text*',
+ marks: '',
+ defining: true,
+ parseDOM: [{ tag: 'summary' }],
+ toDOM: () => ['summary', 0],
+ },
toMarkdown(state, node) {
state.write('<summary>');
state.text(node.textContent, false);
state.write('</summary>');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table.js b/app/assets/javascripts/behaviors/markdown/nodes/table.js
index a7fcb9227cd..c766f7f1fba 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table.js
@@ -1,25 +1,15 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Table extends Node {
- get name() {
- return 'table';
- }
-
- get schema() {
- return {
- content: 'table_head table_body',
- group: 'block',
- isolating: true,
- parseDOM: [{ tag: 'table' }],
- toDOM: () => ['table', 0],
- };
- }
-
+export default () => ({
+ name: 'table',
+ schema: {
+ content: 'table_head table_body',
+ group: 'block',
+ isolating: true,
+ parseDOM: [{ tag: 'table' }],
+ toDOM: () => ['table', 0],
+ },
toMarkdown(state, node) {
state.renderContent(node);
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js
index 403556dc0c8..0a49fb558ae 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js
@@ -1,24 +1,14 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class TableBody extends Node {
- get name() {
- return 'table_body';
- }
-
- get schema() {
- return {
- content: 'table_row+',
- parseDOM: [{ tag: 'tbody' }],
- toDOM: () => ['tbody', 0],
- };
- }
-
- toMarkdown(state, node) {
+export default () => ({
+ name: 'table_body',
+ schema: {
+ content: 'table_row+',
+ parseDOM: [{ tag: 'tbody' }],
+ toDOM: () => ['tbody', 0],
+ },
+ toMarkdown: (state, node) => {
state.flushClose(1);
state.renderContent(node);
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js
index ebb66cd4da5..f46344ba43c 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js
@@ -1,35 +1,25 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class TableCell extends Node {
- get name() {
- return 'table_cell';
- }
-
- get schema() {
- return {
- attrs: {
- header: { default: false },
- align: { default: null },
+export default () => ({
+ name: 'table_cell',
+ schema: {
+ attrs: {
+ header: { default: false },
+ align: { default: null },
+ },
+ content: 'inline*',
+ isolating: true,
+ parseDOM: [
+ {
+ tag: 'td, th',
+ getAttrs: (el) => ({
+ header: el.tagName === 'TH',
+ align: el.getAttribute('align') || el.style.textAlign,
+ }),
},
- content: 'inline*',
- isolating: true,
- parseDOM: [
- {
- tag: 'td, th',
- getAttrs: (el) => ({
- header: el.tagName === 'TH',
- align: el.getAttribute('align') || el.style.textAlign,
- }),
- },
- ],
- toDOM: (node) => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0],
- };
- }
-
- toMarkdown(state, node) {
+ ],
+ toDOM: (node) => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0],
+ },
+ toMarkdown: (state, node) => {
state.renderInline(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js
index 4cb94bf088c..2e9b53ee0ac 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js
@@ -1,24 +1,14 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class TableHead extends Node {
- get name() {
- return 'table_head';
- }
-
- get schema() {
- return {
- content: 'table_header_row',
- parseDOM: [{ tag: 'thead' }],
- toDOM: () => ['thead', 0],
- };
- }
-
- toMarkdown(state, node) {
+export default () => ({
+ name: 'table_head',
+ schema: {
+ content: 'table_header_row',
+ parseDOM: [{ tag: 'thead' }],
+ toDOM: () => ['thead', 0],
+ },
+ toMarkdown: (state, node) => {
state.flushClose(1);
state.renderContent(node);
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
index 2cb2bb9e7fe..d8aa497066c 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
@@ -1,31 +1,23 @@
-/* eslint-disable class-methods-use-this */
-
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
import TableRow from './table_row';
const CENTER_ALIGN = 'center';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class TableHeaderRow extends TableRow {
- get name() {
- return 'table_header_row';
- }
-
- get schema() {
- return {
- content: 'table_cell+',
- parseDOM: [
- {
- tag: 'thead tr',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- },
- ],
- toDOM: () => ['tr', 0],
- };
- }
-
- toMarkdown(state, node) {
- const cellWidths = super.toMarkdown(state, node);
+export default () => ({
+ name: 'table_header_row',
+ schema: {
+ content: 'table_cell+',
+ parseDOM: [
+ {
+ tag: 'thead tr',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ },
+ ],
+ toDOM: () => ['tr', 0],
+ },
+ toMarkdown: (state, node) => {
+ const cellWidths = TableRow().toMarkdown(state, node);
state.flushClose(1);
@@ -40,5 +32,5 @@ export default class TableHeaderRow extends TableRow {
state.write('|');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
index db9072acc3a..4a0256c4644 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
@@ -1,35 +1,26 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { __ } from '~/locale';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter
-export default class TableOfContents extends Node {
- get name() {
- return 'table_of_contents';
- }
-
- get schema() {
- return {
- group: 'block',
- atom: true,
- parseDOM: [
- {
- tag: 'ul.section-nav',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- },
- {
- tag: 'p.table-of-contents',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- },
- ],
- toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')],
- };
- }
-
- toMarkdown(state, node) {
+export default () => ({
+ name: 'table_of_contents',
+ schema: {
+ group: 'block',
+ atom: true,
+ parseDOM: [
+ {
+ tag: 'ul.section-nav',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ },
+ {
+ tag: 'p.table-of-contents',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ },
+ ],
+ toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')],
+ },
+ toMarkdown: (state, node) => {
state.write('[[_TOC_]]');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js
index 5852502773a..3830dae4f0d 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js
@@ -1,22 +1,12 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class TableRow extends Node {
- get name() {
- return 'table_row';
- }
-
- get schema() {
- return {
- content: 'table_cell+',
- parseDOM: [{ tag: 'tr' }],
- toDOM: () => ['tr', 0],
- };
- }
-
- toMarkdown(state, node) {
+export default () => ({
+ name: 'table_row',
+ schema: {
+ content: 'table_cell+',
+ parseDOM: [{ tag: 'tr' }],
+ toDOM: () => ['tr', 0],
+ },
+ toMarkdown: (state, node) => {
const cellWidths = [];
state.flushClose(1);
@@ -34,5 +24,5 @@ export default class TableRow extends Node {
state.closeBlock(node);
return cellWidths;
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
index 35ba2eb0674..3c3812ad8f7 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
@@ -1,29 +1,20 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
-export default class TaskList extends Node {
- get name() {
- return 'task_list';
- }
-
- get schema() {
- return {
- group: 'block',
- content: '(task_list_item|list_item)+',
- parseDOM: [
- {
- priority: HIGHER_PARSE_RULE_PRIORITY,
- tag: 'ul.task-list',
- },
- ],
- toDOM: () => ['ul', { class: 'task-list' }, 0],
- };
- }
-
+export default () => ({
+ name: 'task_list',
+ schema: {
+ group: 'block',
+ content: '(task_list_item|list_item)+',
+ parseDOM: [
+ {
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ tag: 'ul.task-list',
+ },
+ ],
+ toDOM: () => ['ul', { class: 'task-list' }, 0],
+ },
toMarkdown(state, node) {
state.renderList(node, ' ', () => '* ');
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
index 56c2b17286d..10ffce9b1b8 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
@@ -1,50 +1,38 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
-export default class TaskListItem extends Node {
- get name() {
- return 'task_list_item';
- }
-
- get schema() {
- return {
- attrs: {
- done: {
- default: false,
- },
+export default () => ({
+ name: 'task_list_item',
+ schema: {
+ attrs: {
+ done: {
+ default: false,
},
- defining: true,
- draggable: false,
- content: 'paragraph block*',
- parseDOM: [
- {
- priority: HIGHER_PARSE_RULE_PRIORITY,
- tag: 'li.task-list-item',
- getAttrs: (el) => {
- const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox');
- return { done: checkbox && checkbox.checked };
- },
+ },
+ defining: true,
+ draggable: false,
+ content: 'paragraph block*',
+ parseDOM: [
+ {
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ tag: 'li.task-list-item',
+ getAttrs: (el) => {
+ const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox');
+ return { done: checkbox && checkbox.checked };
},
- ],
- toDOM(node) {
- return [
- 'li',
- { class: 'task-list-item' },
- [
- 'input',
- { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done },
- ],
- ['div', { class: 'todo-content' }, 0],
- ];
},
- };
- }
-
+ ],
+ toDOM(node) {
+ return [
+ 'li',
+ { class: 'task-list-item' },
+ ['input', { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }],
+ ['div', { class: 'todo-content' }, 0],
+ ];
+ },
+ },
toMarkdown(state, node) {
state.write(`[${node.attrs.done ? 'x' : ' '}] `);
state.renderContent(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js
index 0dc77a12f5c..0e1f0bc0e40 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/text.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/text.js
@@ -1,20 +1,11 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
-export default class Text extends Node {
- get name() {
- return 'text';
- }
-
- get schema() {
- return {
- group: 'inline',
- };
- }
-
+export default () => ({
+ name: 'text',
+ schema: {
+ group: 'inline',
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.text(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/video.js b/app/assets/javascripts/behaviors/markdown/nodes/video.js
index 68085c2c416..aa1088826da 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/video.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/video.js
@@ -1,10 +1,4 @@
-import Playable from './playable';
+import playable from './playable';
// Transforms generated HTML back to GFM for Banzai::Filter::VideoLinkFilter
-export default class Video extends Playable {
- constructor() {
- super();
- this.mediaType = 'video';
- this.extraElementAttrs = { width: '400' };
- }
-}
+export default () => playable({ mediaType: 'video', extraElementAttrs: { width: '400' } });
diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
index 1d54a1b0c04..85a991a1ec9 100644
--- a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
@@ -88,7 +88,7 @@ function renderMermaidEl(el, source) {
const iframeEl = document.createElement('iframe');
setAttributes(iframeEl, {
src: getSandboxFrameSrc(),
- sandbox: 'allow-scripts',
+ sandbox: 'allow-scripts allow-popups',
frameBorder: 0,
scrolling: 'no',
width: '100%',
diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js
index 8bea24584cc..1b0f46ff4cb 100644
--- a/app/assets/javascripts/behaviors/markdown/schema.js
+++ b/app/assets/javascripts/behaviors/markdown/schema.js
@@ -1,24 +1,20 @@
import { Schema } from 'prosemirror-model';
import editorExtensions from './editor_extensions';
-const nodes = editorExtensions
- .filter((extension) => extension.type === 'node')
- .reduce(
- (ns, { name, schema }) => ({
- ...ns,
- [name]: schema,
- }),
- {},
- );
+const nodes = editorExtensions.nodes.reduce(
+ (ns, { name, schema }) => ({
+ ...ns,
+ [name]: schema,
+ }),
+ {},
+);
-const marks = editorExtensions
- .filter((extension) => extension.type === 'mark')
- .reduce(
- (ms, { name, schema }) => ({
- ...ms,
- [name]: schema,
- }),
- {},
- );
+const marks = editorExtensions.marks.reduce(
+ (ms, { name, schema }) => ({
+ ...ms,
+ [name]: schema,
+ }),
+ {},
+);
export default new Schema({ nodes, marks });
diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js
index a5f97d7748a..e3e8a380cd5 100644
--- a/app/assets/javascripts/behaviors/markdown/serializer.js
+++ b/app/assets/javascripts/behaviors/markdown/serializer.js
@@ -1,24 +1,20 @@
import { MarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
import editorExtensions from './editor_extensions';
-const nodes = editorExtensions
- .filter((extension) => extension.type === 'node')
- .reduce(
- (ns, { name, toMarkdown }) => ({
- ...ns,
- [name]: toMarkdown,
- }),
- {},
- );
+const nodes = editorExtensions.nodes.reduce(
+ (ns, { name, toMarkdown }) => ({
+ ...ns,
+ [name]: toMarkdown,
+ }),
+ {},
+);
-const marks = editorExtensions
- .filter((extension) => extension.type === 'mark')
- .reduce(
- (ms, { name, toMarkdown }) => ({
- ...ms,
- [name]: toMarkdown,
- }),
- {},
- );
+const marks = editorExtensions.marks.reduce(
+ (ms, { name, toMarkdown }) => ({
+ ...ms,
+ [name]: toMarkdown,
+ }),
+ {},
+);
export default new MarkdownSerializer(nodes, marks);
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index b27dccabdf8..23b66405844 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -131,6 +131,13 @@ export const ITALIC_TEXT = {
customizable: false,
};
+export const STRIKETHROUGH_TEXT = {
+ id: 'editing.strikethroughText',
+ description: __('Strikethrough text'),
+ defaultKeys: ['mod+shift+x'],
+ customizable: false,
+};
+
export const LINK_TEXT = {
id: 'editing.linkText',
description: __('Link text'),
@@ -511,7 +518,14 @@ export const GLOBAL_SHORTCUTS_GROUP = {
export const EDITING_SHORTCUTS_GROUP = {
id: 'editing',
name: __('Editing'),
- keybindings: [BOLD_TEXT, ITALIC_TEXT, LINK_TEXT, TOGGLE_MARKDOWN_PREVIEW, EDIT_RECENT_COMMENT],
+ keybindings: [
+ BOLD_TEXT,
+ ITALIC_TEXT,
+ STRIKETHROUGH_TEXT,
+ LINK_TEXT,
+ TOGGLE_MARKDOWN_PREVIEW,
+ EDIT_RECENT_COMMENT,
+ ],
};
export const WIKI_SHORTCUTS_GROUP = {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 9297b14aac9..4d78c7b56a0 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -173,12 +173,7 @@ export default class Shortcuts {
e.preventDefault();
const canaryCookieName = 'gitlab_canary';
const currentValue = parseBoolean(getCookie(canaryCookieName));
- setCookie(canaryCookieName, (!currentValue).toString(), {
- expires: 365,
- path: '/',
- // next.gitlab.com uses a leading period. See https://gitlab.com/gitlab-org/gitlab/-/issues/350186
- domain: `.${window.location.hostname}`,
- });
+ setCookie(canaryCookieName, (!currentValue).toString(), { expires: 365, path: '/' });
refreshCurrentPage();
}
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index c5ab28e6ec5..8a4fe1a9025 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -63,6 +63,9 @@ export default {
isEmpty() {
return this.blob.rawSize === 0;
},
+ blobSwitcherDocIcon() {
+ return this.blob.richViewer?.fileType === 'csv' ? 'table' : 'document';
+ },
},
watch: {
viewer(newVal, oldVal) {
@@ -90,7 +93,7 @@ export default {
</div>
<div class="gl-sm-display-flex file-actions">
- <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" />
+ <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" :doc-icon="blobSwitcherDocIcon" />
<slot name="actions"></slot>
diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
index 12bcb24b0cc..61baf4fa495 100644
--- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue
+++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
+import { setUrlParams, relativePathToAbsolute, getBaseURL } from '~/lib/utils/url_utility';
import {
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
@@ -56,7 +57,7 @@ export default {
},
computed: {
downloadUrl() {
- return `${this.rawPath}?inline=false`;
+ return setUrlParams({ inline: false }, relativePathToAbsolute(this.rawPath, getBaseURL()));
},
copyDisabled() {
return this.activeViewer === RICH_BLOB_VIEWER;
diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
index b2546d47694..7351df0f93b 100644
--- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
+++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
@@ -21,6 +21,11 @@ export default {
default: SIMPLE_BLOB_VIEWER,
required: false,
},
+ docIcon: {
+ type: String,
+ default: 'document',
+ required: false,
+ },
},
computed: {
isSimpleViewer() {
@@ -62,7 +67,7 @@ export default {
:aria-label="$options.RICH_BLOB_VIEWER_TITLE"
:title="$options.RICH_BLOB_VIEWER_TITLE"
:selected="isRichViewer"
- icon="document"
+ :icon="docIcon"
category="primary"
variant="default"
class="js-blob-viewer-switch-btn"
diff --git a/app/assets/javascripts/blob/csv/csv_viewer.vue b/app/assets/javascripts/blob/csv/csv_viewer.vue
index 1f9d20a487f..169167625e0 100644
--- a/app/assets/javascripts/blob/csv/csv_viewer.vue
+++ b/app/assets/javascripts/blob/csv/csv_viewer.vue
@@ -14,6 +14,11 @@ export default {
type: String,
required: true,
},
+ remoteFile: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -23,14 +28,29 @@ export default {
};
},
mounted() {
- const parsed = Papa.parse(this.csv, { skipEmptyLines: true });
- this.items = parsed.data;
-
- if (parsed.errors.length) {
- this.papaParseErrors = parsed.errors;
+ if (!this.remoteFile) {
+ const parsed = Papa.parse(this.csv, { skipEmptyLines: true });
+ this.handleParsedData(parsed);
+ } else {
+ Papa.parse(this.csv, {
+ download: true,
+ skipEmptyLines: true,
+ complete: (parsed) => {
+ this.handleParsedData(parsed);
+ },
+ });
}
+ },
+ methods: {
+ handleParsedData(parsed) {
+ this.items = parsed.data;
- this.loading = false;
+ if (parsed.errors.length) {
+ this.papaParseErrors = parsed.errors;
+ }
+
+ this.loading = false;
+ },
},
};
</script>
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 9fa70ce3c62..7eb699eacbe 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
export default class TemplateSelector {
constructor({ dropdown, data, pattern, wrapper, editor, $input } = {}) {
@@ -10,10 +11,9 @@ export default class TemplateSelector {
this.dropdown = dropdown;
this.$dropdownContainer = wrapper;
this.$filenameInput = $input || $('#file_name');
- this.$dropdownIcon = $('.dropdown-menu-toggle-icon', dropdown);
- this.$loadingIcon = $(
- '<div class="gl-spinner gl-spinner-orange gl-spinner-sm gl-absolute gl-top-3 gl-right-3 gl-display-none"></div>',
- ).insertAfter(this.$dropdownIcon);
+ this.dropdownIcon = dropdown[0].querySelector('.dropdown-menu-toggle-icon');
+ this.loadingIcon = loadingIconForLegacyJS({ classes: ['gl-display-none'] });
+ this.dropdownIcon.parentNode.insertBefore(this.loadingIcon, this.dropdownIcon.nextSibling);
this.initDropdown(dropdown, data);
this.listenForFilenameInput();
@@ -78,7 +78,12 @@ export default class TemplateSelector {
setEditorContent(file, { skipFocus } = {}) {
if (!file) return;
- const newValue = file.content;
+ let newValue = file.content;
+
+ const urlParams = new URLSearchParams(window.location.search);
+ if (urlParams.has('issue[description]')) {
+ newValue += `\n${urlParams.get('issue[description]')}`;
+ }
this.editor.setValue(newValue, 1);
@@ -95,12 +100,12 @@ export default class TemplateSelector {
}
startLoadingSpinner() {
- this.$loadingIcon.removeClass('gl-display-none');
- this.$dropdownIcon.addClass('gl-display-none');
+ this.loadingIcon.classList.remove('gl-display-none');
+ this.dropdownIcon.classList.add('gl-display-none');
}
stopLoadingSpinner() {
- this.$loadingIcon.addClass('gl-display-none');
- this.$dropdownIcon.removeClass('gl-display-none');
+ this.loadingIcon.classList.add('gl-display-none');
+ this.dropdownIcon.classList.remove('gl-display-none');
}
}
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 2d9ffda06d0..425de914c17 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -2,7 +2,6 @@
import $ from 'jquery';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
-import initCodeQualityWalkthrough from '~/code_quality_walkthrough';
import createFlash from '~/flash';
import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
@@ -39,13 +38,6 @@ const initPopovers = () => {
}
};
-const initCodeQualityWalkthroughStep = () => {
- const codeQualityWalkthroughEl = document.querySelector('.js-code-quality-walkthrough');
- if (codeQualityWalkthroughEl) {
- initCodeQualityWalkthrough(codeQualityWalkthroughEl);
- }
-};
-
export const initUploadForm = () => {
const uploadBlobForm = $('.js-upload-blob-form');
if (uploadBlobForm.length) {
@@ -71,7 +63,7 @@ export default () => {
const isMarkdown = editBlobForm.data('is-markdown');
const previewMarkdownPath = editBlobForm.data('previewMarkdownPath');
const commitButton = $('.js-commit-button');
- const cancelLink = $('.btn.btn-cancel');
+ const cancelLink = $('#cancel-changes');
import('./edit_blob')
.then(({ default: EditBlob } = {}) => {
@@ -84,7 +76,6 @@ export default () => {
previewMarkdownPath,
});
initPopovers();
- initCodeQualityWalkthroughStep();
})
.catch((e) =>
createFlash({
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 7e4d3ebb686..96cc774a280 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,5 +1,6 @@
import { sortBy, cloneDeep } from 'lodash';
-import { isGid } from '~/graphql_shared/utils';
+import { TYPE_BOARD, TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants';
+import { isGid, convertToGraphQLId } from '~/graphql_shared/utils';
import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType } from './constants';
export function getMilestone() {
@@ -80,19 +81,22 @@ export function formatListsPageInfo(lists) {
}
export function fullBoardId(boardId) {
- return `gid://gitlab/Board/${boardId}`;
+ if (!boardId) {
+ return null;
+ }
+ return convertToGraphQLId(TYPE_BOARD, boardId);
}
export function fullIterationId(id) {
- return `gid://gitlab/Iteration/${id}`;
+ return convertToGraphQLId(TYPE_ITERATION, id);
}
export function fullUserId(id) {
- return `gid://gitlab/User/${id}`;
+ return convertToGraphQLId(TYPE_USER, id);
}
export function fullMilestoneId(id) {
- return `gid://gitlab/Milestone/${id}`;
+ return convertToGraphQLId(TYPE_MILESTONE, id);
}
export function fullLabelId(label) {
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 45192b5304a..95d4fd5bc0a 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -151,10 +151,10 @@ export default {
});
}
- if (this.filterParams['not[iteration_id]']) {
+ if (this.filterParams['not[iterationId]']) {
filteredSearchValue.push({
- type: 'iteration_id',
- value: { data: this.filterParams['not[iteration_id]'], operator: '!=' },
+ type: 'iteration',
+ value: { data: this.filterParams['not[iterationId]'], operator: '!=' },
});
}
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index cc048e2af1a..5fcf9514708 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -1,11 +1,9 @@
<script>
import { GlModal, GlAlert } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
-import { TYPE_USER, TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants';
-import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { getParameterByName, visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
-import { fullLabelId } from '../boards_util';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { formType } from '../constants';
import createBoardMutation from '../graphql/board_create.mutation.graphql';
@@ -18,6 +16,7 @@ const boardDefaults = {
name: '',
labels: [],
milestone: {},
+ iterationCadence: {},
iteration: {},
assignee: {},
weight: null,
@@ -44,6 +43,7 @@ export default {
BoardConfigurationOptions,
GlAlert,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
fullPath: {
default: '',
@@ -158,33 +158,8 @@ export default {
groupPath: this.isGroupBoard ? this.fullPath : undefined,
};
},
- issueBoardScopeMutationVariables() {
- return {
- weight: this.board.weight,
- assigneeId: this.board.assignee?.id
- ? convertToGraphQLId(TYPE_USER, this.board.assignee.id)
- : null,
- // Temporarily converting to milestone ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779
- milestoneId: this.board.milestone?.id
- ? convertToGraphQLId(TYPE_MILESTONE, getIdFromGraphQLId(this.board.milestone.id))
- : null,
- // Temporarily converting to iteration ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779
- iterationId: this.board.iteration?.id
- ? convertToGraphQLId(TYPE_ITERATION, getIdFromGraphQLId(this.board.iteration.id))
- : null,
- };
- },
- boardScopeMutationVariables() {
- return {
- labelIds: this.board.labels.map(fullLabelId),
- ...(this.isIssueBoard && this.issueBoardScopeMutationVariables),
- };
- },
mutationVariables() {
- return {
- ...this.baseMutationVariables,
- ...(this.scopedIssueBoardFeatureEnabled ? this.boardScopeMutationVariables : {}),
- };
+ return this.baseMutationVariables;
},
},
mounted() {
@@ -259,9 +234,12 @@ export default {
this.board = { ...boardDefaults, ...this.currentBoard };
}
},
- setIteration(iterationId) {
+ setIteration(iteration) {
+ if (this.glFeatures.iterationCadences) {
+ this.board.iterationCadenceId = iteration.iterationCadenceId;
+ }
this.$set(this.board, 'iteration', {
- id: iterationId,
+ id: iteration.id,
});
},
setBoardLabels(labels) {
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 6835d83a66c..46b28d20da9 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -89,10 +89,6 @@ export default {
listTitle() {
return this.list?.label?.description || this.list?.assignee?.name || this.list.title || '';
},
- listIterationPeriod() {
- const iteration = this.list?.iteration;
- return iteration ? this.getIterationPeriod(iteration) : '';
- },
isIterationList() {
return this.listType === ListType.iteration;
},
@@ -108,9 +104,6 @@ export default {
showIterationListDetails() {
return this.isIterationList && this.showListDetails;
},
- iterationCadencesAvailable() {
- return this.isIterationList && this.glFeatures.iterationCadences;
- },
showListDetails() {
return !this.list.collapsed || !this.isSwimlanesHeader;
},
@@ -344,13 +337,6 @@ export default {
class="board-title-main-text gl-text-truncate"
>
{{ listTitle }}
- <span
- v-if="iterationCadencesAvailable"
- class="gl-display-inline-block gl-text-gray-400"
- data-testid="board-list-iteration-period"
- >
- {{ listIterationPeriod }}</span
- >
</span>
<span
v-if="listType === 'assignee'"
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 6dbb1ea0050..91fdfd668fc 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -101,6 +101,7 @@ export default {
},
update(data) {
const board = data.workspace?.board;
+ this.setBoardConfig(board);
return {
...board,
labels: board?.labels?.nodes,
@@ -170,7 +171,7 @@ export default {
eventHub.$off('showBoardModal', this.showPage);
},
methods: {
- ...mapActions(['setError']),
+ ...mapActions(['setError', 'setBoardConfig']),
showPage(page) {
this.currentPage = page;
},
@@ -315,9 +316,7 @@ export default {
<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.',
- )
+ s__('IssueBoards|Some of your boards are hidden, add a license to see them again.')
}}
</gl-dropdown-item>
</div>
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
deleted file mode 100644
index 72586970008..00000000000
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import { transformBoardConfig } from 'ee_else_ce/boards/boards_util';
-import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
-import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
-import { updateHistory } from '~/lib/utils/url_utility';
-import FilteredSearchContainer from '../filtered_search/container';
-import vuexstore from './stores';
-
-export default class FilteredSearchBoards extends FilteredSearchManager {
- constructor(store, updateUrl = false, cantEdit = []) {
- super({
- page: 'boards',
- isGroupDecendent: true,
- stateFiltersSelector: '.issues-state-filters',
- isGroup: IS_EE,
- useDefaultState: false,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- });
-
- this.store = store;
- this.updateUrl = updateUrl;
-
- // Issue boards is slightly different, we handle all the requests async
- // instead or reloading the page, we just re-fire the list ajax requests
- this.isHandledAsync = true;
- this.cantEdit = cantEdit.filter((i) => typeof i === 'string');
- this.cantEditWithValue = cantEdit.filter((i) => typeof i === 'object');
-
- 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
- // only to unpack the params and do another validation inside
- // 'performSearch' and 'setFilter' vuex actions.
- if (boardConfigPath !== '') {
- const filterPath = window.location.search ? `${window.location.search}&` : '?';
- updateHistory({
- url: `${filterPath}${transformBoardConfig(vuexstore.state.boardConfig)}`,
- });
- }
- }
- }
-
- updateObject(path) {
- const groupByParam = new URLSearchParams(window.location.search).get('group_by');
- this.store.path = `${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`;
-
- updateHistory({
- url: `?${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`,
- });
- vuexstore.dispatch('performSearch');
- }
-
- removeTokens() {
- const tokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token');
-
- // Remove all the tokens as they will be replaced by the search manager
- [].forEach.call(tokens, (el) => {
- el.parentNode.removeChild(el);
- });
-
- this.filteredSearchInput.value = '';
- }
-
- updateTokens() {
- this.removeTokens();
-
- this.loadSearchParamsFromURL();
-
- // Get the placeholder back if search is empty
- this.filteredSearchInput.dispatchEvent(new Event('input'));
- }
-
- canEdit(tokenName, tokenValue) {
- if (this.cantEdit.includes(tokenName)) return false;
- return (
- this.cantEditWithValue.findIndex(
- (token) => token.name === tokenName && token.value === tokenValue,
- ) === -1
- );
- }
-}
diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js
index 95863d4d5ac..d066a5d002e 100644
--- a/app/assets/javascripts/boards/graphql.js
+++ b/app/assets/javascripts/boards/graphql.js
@@ -10,5 +10,6 @@ export const gqlClient = createDefaultClient(
return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object);
},
},
+ batchMax: 2,
},
);
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index f6073f9d981..b31b56e6839 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -8,8 +8,6 @@ import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_t
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 toggleFocusMode from '~/boards/toggle_focus';
@@ -30,6 +28,12 @@ const apolloProvider = new VueApollo({
function mountBoardApp(el) {
const { boardId, groupId, fullPath, rootPath } = el.dataset;
+ store.dispatch('fetchBoard', {
+ fullPath,
+ fullBoardId: fullBoardId(boardId),
+ boardType: el.dataset.parent,
+ });
+
store.dispatch('setInitialBoardData', {
boardId,
fullBoardId: fullBoardId(boardId),
@@ -37,30 +41,8 @@ function mountBoardApp(el) {
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,
@@ -110,10 +92,14 @@ export default () => {
}
});
- if (gon?.features?.issueBoardsFilteredSearch) {
- const { releasesFetchPath } = $boardApp.dataset;
- initBoardsFilteredSearch(apolloProvider, isLoggedIn(), releasesFetchPath);
- }
+ const { releasesFetchPath, epicFeatureAvailable, iterationFeatureAvailable } = $boardApp.dataset;
+ initBoardsFilteredSearch(
+ apolloProvider,
+ isLoggedIn(),
+ releasesFetchPath,
+ parseBoolean(epicFeatureAvailable),
+ parseBoolean(iterationFeatureAvailable),
+ );
mountBoardApp($boardApp);
diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
index 327fb9ba8d7..bb659eb075a 100644
--- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
+++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
@@ -4,7 +4,13 @@ import store from '~/boards/stores';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
-export default (apolloProvider, isSignedIn, releasesFetchPath) => {
+export default (
+ apolloProvider,
+ isSignedIn,
+ releasesFetchPath,
+ epicFeatureAvailable,
+ iterationFeatureAvailable,
+) => {
const el = document.getElementById('js-issue-board-filtered-search');
const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
@@ -23,6 +29,8 @@ export default (apolloProvider, isSignedIn, releasesFetchPath) => {
initialFilterParams,
isSignedIn,
releasesFetchPath,
+ epicFeatureAvailable,
+ iterationFeatureAvailable,
},
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider,
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 1ebfcfc331b..82307da2572 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -36,6 +36,8 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { gqlClient } from '../graphql';
+import projectBoardQuery from '../graphql/project_board.query.graphql';
+import groupBoardQuery from '../graphql/group_board.query.graphql';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
@@ -46,10 +48,44 @@ import projectBoardMilestonesQuery from '../graphql/project_board_milestones.que
import * as types from './mutation_types';
export default {
+ fetchBoard: ({ commit, dispatch }, { fullPath, fullBoardId, boardType }) => {
+ const variables = {
+ fullPath,
+ boardId: fullBoardId,
+ };
+
+ return gqlClient
+ .query({
+ query: boardType === BoardType.group ? groupBoardQuery : projectBoardQuery,
+ variables,
+ })
+ .then(({ data }) => {
+ const board = data.workspace?.board;
+ commit(types.RECEIVE_BOARD_SUCCESS, board);
+ dispatch('setBoardConfig', board);
+ })
+ .catch(() => commit(types.RECEIVE_BOARD_FAILURE));
+ },
+
setInitialBoardData: ({ commit }, data) => {
commit(types.SET_INITIAL_BOARD_DATA, data);
},
+ setBoardConfig: ({ commit }, board) => {
+ const config = {
+ milestoneId: board.milestone?.id || null,
+ milestoneTitle: board.milestone?.title || null,
+ iterationId: board.iteration?.id || null,
+ iterationTitle: board.iteration?.title || null,
+ assigneeId: board.assignee?.id || null,
+ assigneeUsername: board.assignee?.username || null,
+ labels: board.labels?.nodes || [],
+ labelIds: board.labels?.nodes?.map((label) => label.id) || [],
+ weight: board.weight,
+ };
+ commit(types.SET_BOARD_CONFIG, config);
+ },
+
setActiveId({ commit }, { id, sidebarType }) {
commit(types.SET_ACTIVE_ID, { id, sidebarType });
},
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 31b78014525..668a3b5e0f9 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -1,4 +1,7 @@
+export const RECEIVE_BOARD_SUCCESS = 'RECEIVE_BOARD_SUCCESS';
+export const RECEIVE_BOARD_FAILURE = 'RECEIVE_BOARD_FAILURE';
export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA';
+export const SET_BOARD_CONFIG = 'SET_BOARD_CONFIG';
export const SET_FILTERS = 'SET_FILTERS';
export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS';
export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 2a2ce7652e6..9a50dcf05b8 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -33,10 +33,20 @@ export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId
};
export default {
+ [mutationTypes.RECEIVE_BOARD_SUCCESS]: (state, board) => {
+ state.board = {
+ ...board,
+ labels: board?.labels?.nodes || [],
+ };
+ },
+
+ [mutationTypes.RECEIVE_BOARD_FAILURE]: (state) => {
+ state.error = s__('Boards|An error occurred while fetching the board. Please reload the page.');
+ },
+
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
const {
allowSubEpics,
- boardConfig,
boardId,
boardType,
disabled,
@@ -45,7 +55,6 @@ export default {
issuableType,
} = data;
state.allowSubEpics = allowSubEpics;
- state.boardConfig = boardConfig;
state.boardId = boardId;
state.boardType = boardType;
state.disabled = disabled;
@@ -54,6 +63,10 @@ export default {
state.issuableType = issuableType;
},
+ [mutationTypes.SET_BOARD_CONFIG](state, boardConfig) {
+ state.boardConfig = boardConfig;
+ },
+
[mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => {
state.boardLists = lists;
},
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 80c51c966d2..7af4e5a8798 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,6 +1,7 @@
import { inactiveId, ListType } from '~/boards/constants';
export default () => ({
+ board: {},
boardType: null,
issuableType: null,
fullPath: null,
diff --git a/app/assets/javascripts/branches/ajax_loading_spinner.js b/app/assets/javascripts/branches/ajax_loading_spinner.js
deleted file mode 100644
index 79f4f919f3d..00000000000
--- a/app/assets/javascripts/branches/ajax_loading_spinner.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import $ from 'jquery';
-
-export default class AjaxLoadingSpinner {
- static init() {
- const $elements = $('.js-ajax-loading-spinner');
- $elements.on('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
- }
-
- static ajaxBeforeSend(e) {
- const button = e.target;
- const newButton = document.createElement('button');
- newButton.classList.add('btn', 'btn-default', 'disabled', 'gl-button');
- newButton.setAttribute('disabled', 'disabled');
-
- const spinner = document.createElement('span');
- spinner.classList.add('align-text-bottom', 'gl-spinner', 'gl-spinner-sm', 'gl-spinner-orange');
- newButton.appendChild(spinner);
-
- button.classList.add('hidden');
- button.parentNode.insertBefore(newButton, button.nextSibling);
-
- $(button).one('ajax:error', () => {
- newButton.remove();
- button.classList.remove('hidden');
- });
-
- $(button).one('ajax:success', () => {
- $(button).off('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
- });
- }
-}
diff --git a/app/assets/javascripts/captcha/apollo_captcha_link.js b/app/assets/javascripts/captcha/apollo_captcha_link.js
index d63ffaf5f1a..2d154139c7b 100644
--- a/app/assets/javascripts/captcha/apollo_captcha_link.js
+++ b/app/assets/javascripts/captcha/apollo_captcha_link.js
@@ -12,7 +12,7 @@ export const apolloCaptchaLink = new ApolloLink((operation, forward) =>
const spamLogId = captchaError.extensions.spam_log_id;
return new Observable((observer) => {
- import('~/captcha/wait_for_captcha_to_be_solved')
+ import('jh_else_ce/captcha/wait_for_captcha_to_be_solved')
.then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey))
.then((captchaResponse) => {
// If the captcha was solved correctly, we re-do our action while setting
diff --git a/app/assets/javascripts/captcha/captcha_modal.vue b/app/assets/javascripts/captcha/captcha_modal.vue
index a98a52a3130..b8b90b04beb 100644
--- a/app/assets/javascripts/captcha/captcha_modal.vue
+++ b/app/assets/javascripts/captcha/captcha_modal.vue
@@ -1,7 +1,7 @@
<script>
-// NOTE 1: This is similar to recaptcha_modal.vue, but it directly uses the reCAPTCHA Javascript API
-// (https://developers.google.com/recaptcha/docs/display#js_api) and gl-modal, rather than relying
-// on the form-based ReCAPTCHA HTML being pre-rendered by the backend and using deprecated-modal.
+// NOTE 1: This modal directly uses the reCAPTCHA Javascript API
+// (https://developers.google.com/recaptcha/docs/display#js_api) and gl-modal,
+// rather than relying form-based reCAPTCHA HTML being pre-rendered by the backend.
// NOTE 2: Even though this modal currently only supports reCAPTCHA, we use 'captcha' instead
// of 'recaptcha' throughout the code, so that we can easily add support for future alternative
diff --git a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
index fdab188f6be..19fde2500f1 100644
--- a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
+++ b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
@@ -9,7 +9,9 @@ function needsCaptchaResponse(err) {
const showCaptchaModalAndResubmit = async (axios, data, errConfig) => {
// NOTE: We asynchronously import and unbox the module. Since this is included globally, we don't
// do a regular import because that would increase the size of the webpack bundle.
- const { waitForCaptchaToBeSolved } = await import('~/captcha/wait_for_captcha_to_be_solved');
+ const { waitForCaptchaToBeSolved } = await import(
+ 'jh_else_ce/captcha/wait_for_captcha_to_be_solved'
+ );
// show the CAPTCHA modal and wait for it to be solved or closed
const captchaResponse = await waitForCaptchaToBeSolved(data.captcha_site_key);
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue
index d541e89756a..8db4cba529f 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue
@@ -103,7 +103,7 @@ export default {
class="gl-mr-4"
:loading="loading"
category="primary"
- variant="success"
+ variant="confirm"
data-testid="ci-lint-validate"
@click="lint"
>{{ __('Validate') }}</gl-button
diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
new file mode 100644
index 00000000000..d70ade36fe9
--- /dev/null
+++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
+import Api, { DEFAULT_PER_PAGE } from '~/api';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { __ } from '~/locale';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlLink,
+ GlLoadingIcon,
+ GlPagination,
+ GlTable,
+ TimeagoTooltip,
+ },
+ inject: ['projectId'],
+ docsLink: helpPagePath('ci/secure_files/index'),
+ DEFAULT_PER_PAGE,
+ i18n: {
+ pagination: {
+ next: __('Next'),
+ prev: __('Prev'),
+ },
+ title: __('Secure Files'),
+ overviewMessage: __(
+ 'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.',
+ ),
+ moreInformation: __('More information'),
+ },
+ data() {
+ return {
+ page: 1,
+ totalItems: 0,
+ loading: false,
+ projectSecureFiles: [],
+ };
+ },
+ fields: [
+ {
+ key: 'name',
+ label: __('Filename'),
+ },
+ {
+ key: 'permissions',
+ label: __('Permissions'),
+ },
+ {
+ key: 'created_at',
+ label: __('Uploaded'),
+ },
+ ],
+ computed: {
+ fields() {
+ return this.$options.fields;
+ },
+ },
+ watch: {
+ page(newPage) {
+ this.getProjectSecureFiles(newPage);
+ },
+ },
+ created() {
+ this.getProjectSecureFiles();
+ },
+ methods: {
+ async getProjectSecureFiles(page) {
+ this.loading = true;
+ const response = await Api.projectSecureFiles(this.projectId, { page });
+
+ this.totalItems = parseInt(response.headers?.['x-total'], 10) || 0;
+
+ this.projectSecureFiles = response.data;
+
+ this.loading = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h1 data-testid="title" class="gl-font-size-h1 gl-mt-3 gl-mb-0">{{ $options.i18n.title }}</h1>
+
+ <p>
+ <span data-testid="info-message" class="gl-mr-2">
+ {{ $options.i18n.overviewMessage }}
+ <gl-link :href="$options.docsLink" target="_blank">{{
+ $options.i18n.moreInformation
+ }}</gl-link>
+ </span>
+ </p>
+
+ <gl-table
+ :busy="loading"
+ :fields="fields"
+ :items="projectSecureFiles"
+ tbody-tr-class="js-ci-secure-files-row"
+ data-qa-selector="ci_secure_files_table_content"
+ sort-by="key"
+ sort-direction="asc"
+ stacked="lg"
+ table-class="text-secondary"
+ show-empty
+ sort-icon-left
+ no-sort-reset
+ >
+ <template #table-busy>
+ <gl-loading-icon size="lg" class="gl-my-5" />
+ </template>
+
+ <template #cell(name)="{ item }">
+ {{ item.name }}
+ </template>
+
+ <template #cell(permissions)="{ item }">
+ {{ item.permissions }}
+ </template>
+
+ <template #cell(created_at)="{ item }">
+ <timeago-tooltip :time="item.created_at" />
+ </template>
+ </gl-table>
+ <gl-pagination
+ v-if="!loading"
+ v-model="page"
+ :per-page="$options.DEFAULT_PER_PAGE"
+ :total-items="totalItems"
+ :next-text="$options.i18n.pagination.next"
+ :prev-text="$options.i18n.pagination.prev"
+ align="center"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_secure_files/index.js b/app/assets/javascripts/ci_secure_files/index.js
new file mode 100644
index 00000000000..18b4ac6866e
--- /dev/null
+++ b/app/assets/javascripts/ci_secure_files/index.js
@@ -0,0 +1,17 @@
+import Vue from 'vue';
+import SecureFilesList from './components/secure_files_list.vue';
+
+export const initCiSecureFiles = (selector = '#js-ci-secure-files') => {
+ const containerEl = document.querySelector(selector);
+ const { projectId } = containerEl.dataset;
+
+ return new Vue({
+ el: containerEl,
+ provide: {
+ projectId,
+ },
+ render(createElement) {
+ return createElement(SecureFilesList);
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
index 4ab9b36058d..4156717908d 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -8,8 +8,12 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link
export default {
i18n: {
+ copyTrigger: s__('Pipelines|Copy trigger token'),
editButton: s__('Pipelines|Edit'),
- revokeButton: s__('Pipelines|Revoke'),
+ revokeButton: s__('Pipelines|Revoke trigger'),
+ revokeButtonConfirm: s__(
+ 'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?',
+ ),
},
components: {
GlTable,
@@ -72,7 +76,7 @@ export default {
:text="item.token"
data-testid="clipboard-btn"
data-qa-selector="clipboard_button"
- :title="s__('Pipelines|Copy trigger token')"
+ :title="$options.i18n.copyTrigger"
css-class="gl-border-none gl-py-0 gl-px-2"
/>
<div class="label-container">
@@ -122,13 +126,9 @@ export default {
:title="$options.i18n.revokeButton"
:aria-label="$options.i18n.revokeButton"
icon="remove"
- variant="warning"
- :data-confirm="
- s__(
- 'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?',
- )
- "
+ :data-confirm="$options.i18n.revokeButtonConfirm"
data-method="delete"
+ data-confirm-btn-variant="danger"
rel="nofollow"
class="gl-ml-3"
data-testid="trigger_revoke_button"
diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
index 065cb4f5616..055e2f83e33 100644
--- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
@@ -3,7 +3,6 @@ import SecretValues from '../behaviors/secret_values';
import CreateItemDropdown from '../create_item_dropdown';
import { parseBoolean } from '../lib/utils/common_utils';
import { s__ } from '../locale';
-import setupToggleButtons from '../toggle_buttons';
const ALL_ENVIRONMENTS_STRING = s__('CiVariable|All environments');
@@ -115,8 +114,6 @@ export default class VariableList {
initRow(rowEl) {
const $row = $(rowEl);
- setupToggleButtons($row[0]);
-
// Reset the resizable textarea
$row.find(this.inputMap.secret_value.selector).css('height', '');
diff --git a/app/assets/javascripts/clusters/agents/components/create_token_button.vue b/app/assets/javascripts/clusters/agents/components/create_token_button.vue
new file mode 100644
index 00000000000..3e1a8994fb8
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/create_token_button.vue
@@ -0,0 +1,246 @@
+<script>
+import {
+ GlButton,
+ GlModalDirective,
+ GlTooltip,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlAlert,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import Tracking from '~/tracking';
+import AgentToken from '~/clusters_list/components/agent_token.vue';
+import {
+ CREATE_TOKEN_MODAL,
+ EVENT_LABEL_MODAL,
+ EVENT_ACTIONS_OPEN,
+ EVENT_ACTIONS_CLICK,
+ TOKEN_NAME_LIMIT,
+ TOKEN_STATUS_ACTIVE,
+} from '../constants';
+import createNewAgentToken from '../graphql/mutations/create_new_agent_token.mutation.graphql';
+import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
+import { addAgentTokenToStore } from '../graphql/cache_update';
+
+const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL });
+
+export default {
+ components: {
+ AgentToken,
+ GlButton,
+ GlTooltip,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlAlert,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ mixins: [trackingMixin],
+ inject: ['agentName', 'projectPath', 'canAdminCluster'],
+ props: {
+ clusterAgentId: {
+ required: true,
+ type: String,
+ },
+ cursor: {
+ required: true,
+ type: Object,
+ },
+ },
+ modalId: CREATE_TOKEN_MODAL,
+ EVENT_ACTIONS_OPEN,
+ EVENT_ACTIONS_CLICK,
+ EVENT_LABEL_MODAL,
+ TOKEN_NAME_LIMIT,
+ i18n: {
+ createTokenButton: s__('ClusterAgents|Create token'),
+ modalTitle: s__('ClusterAgents|Create agent access token'),
+ unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
+ errorTitle: s__('ClusterAgents|Failed to create a token'),
+ dropdownDisabledHint: s__(
+ 'ClusterAgents|Requires a Maintainer or greater role to perform these actions',
+ ),
+ modalCancel: __('Cancel'),
+ modalClose: __('Close'),
+ tokenNameLabel: __('Name'),
+ tokenDescriptionLabel: __('Description (optional)'),
+ },
+ data() {
+ return {
+ token: {
+ name: null,
+ description: null,
+ },
+ agentToken: null,
+ error: null,
+ loading: false,
+ variables: {
+ agentName: this.agentName,
+ projectPath: this.projectPath,
+ tokenStatus: TOKEN_STATUS_ACTIVE,
+ ...this.cursor,
+ },
+ };
+ },
+ computed: {
+ modalBtnDisabled() {
+ return this.loading || !this.hasTokenName;
+ },
+ hasTokenName() {
+ return Boolean(this.token.name?.length);
+ },
+ },
+ methods: {
+ async createToken() {
+ this.loading = true;
+ this.error = null;
+
+ try {
+ const { errors: tokenErrors, secret } = await this.createAgentTokenMutation();
+
+ if (tokenErrors?.length > 0) {
+ throw new Error(tokenErrors[0]);
+ }
+
+ this.agentToken = secret;
+ } catch (error) {
+ if (error) {
+ this.error = error.message;
+ } else {
+ this.error = this.$options.i18n.unknownError;
+ }
+ } finally {
+ this.loading = false;
+ }
+ },
+ resetModal() {
+ this.agentToken = null;
+ this.token.name = null;
+ this.token.description = null;
+ this.error = null;
+ },
+ closeModal() {
+ this.$refs.modal.hide();
+ },
+ createAgentTokenMutation() {
+ return this.$apollo
+ .mutate({
+ mutation: createNewAgentToken,
+ variables: {
+ input: {
+ clusterAgentId: this.clusterAgentId,
+ name: this.token.name,
+ description: this.token.description,
+ },
+ },
+ update: (store, { data: { clusterAgentTokenCreate } }) => {
+ addAgentTokenToStore(
+ store,
+ clusterAgentTokenCreate,
+ getClusterAgentQuery,
+ this.variables,
+ );
+ },
+ })
+ .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div ref="addToken" class="gl-display-inline-block">
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ :disabled="!canAdminCluster"
+ category="primary"
+ variant="confirm"
+ >{{ $options.i18n.createTokenButton }}
+ </gl-button>
+
+ <gl-tooltip
+ v-if="!canAdminCluster"
+ :target="() => $refs.addToken"
+ :title="$options.i18n.dropdownDisabledHint"
+ />
+ </div>
+
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ :title="$options.i18n.modalTitle"
+ static
+ lazy
+ @hidden="resetModal"
+ @show="track($options.EVENT_ACTIONS_OPEN)"
+ >
+ <gl-alert
+ v-if="error"
+ :title="$options.i18n.errorTitle"
+ :dismissible="false"
+ variant="danger"
+ class="gl-mb-5"
+ >
+ {{ error }}
+ </gl-alert>
+
+ <template v-if="!agentToken">
+ <gl-form-group :label="$options.i18n.tokenNameLabel">
+ <gl-form-input
+ v-model="token.name"
+ :max-length="$options.TOKEN_NAME_LIMIT"
+ :disabled="loading"
+ required
+ />
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.tokenDescriptionLabel">
+ <gl-form-textarea v-model="token.description" :disabled="loading" name="description" />
+ </gl-form-group>
+ </template>
+
+ <agent-token v-else :agent-token="agentToken" :modal-id="$options.modalId" />
+
+ <template #modal-footer>
+ <gl-button
+ v-if="!agentToken && !loading"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="close"
+ data-testid="agent-token-close-button"
+ @click="closeModal"
+ >{{ $options.i18n.modalCancel }}
+ </gl-button>
+
+ <gl-button
+ v-if="!agentToken"
+ :disabled="modalBtnDisabled"
+ :loading="loading"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="create-token"
+ variant="confirm"
+ type="submit"
+ @click="createToken"
+ >{{ $options.i18n.createTokenButton }}
+ </gl-button>
+
+ <gl-button
+ v-else
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="close"
+ variant="confirm"
+ @click="closeModal"
+ >{{ $options.i18n.modalClose }}
+ </gl-button>
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index 63f068a9327..5df3e0811a5 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -143,7 +143,7 @@ export default {
<gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" />
<div v-else>
- <token-table :tokens="tokens" />
+ <token-table :tokens="tokens" :cluster-agent-id="clusterAgent.id" :cursor="cursor" />
<div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-keyset-pagination v-bind="tokenPageInfo" @prev="prevPage" @next="nextPage" />
diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue
index 019fac531d1..fbb39c28d78 100644
--- a/app/assets/javascripts/clusters/agents/components/token_table.vue
+++ b/app/assets/javascripts/clusters/agents/components/token_table.vue
@@ -1,17 +1,17 @@
<script>
-import { GlEmptyState, GlLink, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui';
-import { helpPagePath } from '~/helpers/help_page_helper';
+import { GlEmptyState, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import CreateTokenButton from './create_token_button.vue';
export default {
components: {
GlEmptyState,
- GlLink,
GlTable,
GlTooltip,
GlTruncate,
TimeAgoTooltip,
+ CreateTokenButton,
},
i18n: {
createdBy: s__('ClusterAgents|Created by'),
@@ -19,7 +19,6 @@ export default {
dateCreated: s__('ClusterAgents|Date created'),
description: s__('ClusterAgents|Description'),
lastUsed: s__('ClusterAgents|Last contact'),
- learnMore: s__('ClusterAgents|Learn how to create an agent access token'),
name: s__('ClusterAgents|Name'),
neverUsed: s__('ClusterAgents|Never'),
noTokens: s__('ClusterAgents|This agent has no tokens'),
@@ -30,6 +29,14 @@ export default {
required: true,
type: Array,
},
+ clusterAgentId: {
+ required: true,
+ type: String,
+ },
+ cursor: {
+ required: true,
+ type: Object,
+ },
},
computed: {
fields() {
@@ -61,11 +68,6 @@ export default {
},
];
},
- learnMoreUrl() {
- return helpPagePath('user/clusters/agent/install/index', {
- anchor: 'register-an-agent-with-gitlab',
- });
- },
},
methods: {
createdByName(token) {
@@ -77,11 +79,11 @@ export default {
<template>
<div v-if="tokens.length">
- <div class="gl-text-right gl-my-5">
- <gl-link target="_blank" :href="learnMoreUrl">
- {{ $options.i18n.learnMore }}
- </gl-link>
- </div>
+ <create-token-button
+ class="gl-text-right gl-my-5"
+ :cluster-agent-id="clusterAgentId"
+ :cursor="cursor"
+ />
<gl-table
:items="tokens"
@@ -120,10 +122,9 @@ export default {
</gl-table>
</div>
- <gl-empty-state
- v-else
- :title="$options.i18n.noTokens"
- :primary-button-link="learnMoreUrl"
- :primary-button-text="$options.i18n.learnMore"
- />
+ <gl-empty-state v-else :title="$options.i18n.noTokens">
+ <template #actions>
+ <create-token-button :cluster-agent-id="clusterAgentId" :cursor="cursor" />
+ </template>
+ </gl-empty-state>
</template>
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
index 98d4707b4de..50d8f5e9e40 100644
--- a/app/assets/javascripts/clusters/agents/constants.js
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -37,3 +37,10 @@ export const EVENT_DETAILS = {
export const DEFAULT_ICON = 'token';
export const TOKEN_STATUS_ACTIVE = 'ACTIVE';
+
+export const CREATE_TOKEN_MODAL = 'create-token';
+export const EVENT_LABEL_MODAL = 'agent_token_creation_modal';
+export const EVENT_ACTIONS_OPEN = 'open_modal';
+export const EVENT_ACTIONS_CLICK = 'click_button';
+
+export const TOKEN_NAME_LIMIT = 255;
diff --git a/app/assets/javascripts/clusters/agents/graphql/cache_update.js b/app/assets/javascripts/clusters/agents/graphql/cache_update.js
new file mode 100644
index 00000000000..0219c4150eb
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/cache_update.js
@@ -0,0 +1,24 @@
+import produce from 'immer';
+
+export const hasErrors = ({ errors = [] }) => errors?.length;
+
+export function addAgentTokenToStore(store, clusterAgentTokenCreate, query, variables) {
+ if (!hasErrors(clusterAgentTokenCreate)) {
+ const { token } = clusterAgentTokenCreate;
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ draftData.project.clusterAgent.tokens.nodes.unshift(token);
+ draftData.project.clusterAgent.tokens.count += 1;
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql b/app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql
new file mode 100644
index 00000000000..4a61263ba70
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql
@@ -0,0 +1,11 @@
+#import "../fragments/cluster_agent_token.fragment.graphql"
+
+mutation createNewAgentToken($input: ClusterAgentTokenCreateInput!) {
+ clusterAgentTokenCreate(input: $input) {
+ secret
+ token {
+ ...Token
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js
index ba7b3edba72..8a447f57f00 100644
--- a/app/assets/javascripts/clusters/agents/index.js
+++ b/app/assets/javascripts/clusters/agents/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue';
import apolloProvider from './graphql/provider';
import createRouter from './router';
@@ -16,6 +17,8 @@ export default () => {
canAdminVulnerability,
emptyStateSvgPath,
projectPath,
+ kasAddress,
+ canAdminCluster,
} = el.dataset;
return new Vue({
@@ -28,6 +31,8 @@ export default () => {
canAdminVulnerability,
emptyStateSvgPath,
projectPath,
+ kasAddress,
+ canAdminCluster: parseBoolean(canAdminCluster),
},
render(createElement) {
return createElement(AgentShowPage);
diff --git a/app/assets/javascripts/clusters/components/new_cluster.vue b/app/assets/javascripts/clusters/components/new_cluster.vue
index 2e74ad073c5..8f3e2916270 100644
--- a/app/assets/javascripts/clusters/components/new_cluster.vue
+++ b/app/assets/javascripts/clusters/components/new_cluster.vue
@@ -5,9 +5,9 @@ import { s__ } from '~/locale';
export default {
i18n: {
- title: s__('ClusterIntegration|Enter the details for your Kubernetes cluster'),
+ title: s__('ClusterIntegration|Enter your Kubernetes cluster certificate details'),
information: s__(
- 'ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{linkStart}documentation%{linkEnd} on Kubernetes',
+ 'ClusterIntegration|Enter details about your cluster. %{linkStart}How do I use a certificate to connect to my cluster?%{linkEnd}',
),
},
components: {
@@ -21,7 +21,7 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-pt-4">
<h4>{{ $options.i18n.title }}</h4>
<p>
<gl-sprintf :message="$options.i18n.information">
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index 61c4904aacf..1144ce68e2c 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -1,5 +1,13 @@
<script>
-import { GlLink, GlTable, GlIcon, GlSprintf, GlTooltip, GlPopover } from '@gitlab/ui';
+import {
+ GlLink,
+ GlTable,
+ GlIcon,
+ GlSprintf,
+ GlTooltip,
+ GlTooltipDirective,
+ GlPopover,
+} from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -19,12 +27,18 @@ export default {
TimeAgoTooltip,
DeleteAgentButton,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [timeagoMixin],
AGENT_STATUSES,
troubleshootingLink: helpPagePath('user/clusters/agent/troubleshooting'),
versionUpdateLink: helpPagePath('user/clusters/agent/install/index', {
anchor: 'update-the-agent-version',
}),
+ configHelpLink: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'create-an-agent-without-configuration-file',
+ }),
inject: ['gitlabVersion'],
props: {
agents: {
@@ -256,7 +270,16 @@ export default {
{{ getAgentConfigPath(item.name) }}
</gl-link>
- <span v-else>{{ getAgentConfigPath(item.name) }}</span>
+ <span v-else
+ >{{ $options.i18n.defaultConfigText }}
+ <gl-link
+ v-gl-tooltip
+ :href="$options.configHelpLink"
+ :title="$options.i18n.defaultConfigTooltip"
+ :aria-label="$options.i18n.defaultConfigTooltip"
+ class="gl-vertical-align-middle"
+ ><gl-icon name="question" :size="14" /></gl-link
+ ></span>
</span>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/agent_token.vue b/app/assets/javascripts/clusters_list/components/agent_token.vue
new file mode 100644
index 00000000000..eab3fc3ed63
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agent_token.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlAlert, GlFormInputGroup, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import CodeBlock from '~/vue_shared/components/code_block.vue';
+import { generateAgentRegistrationCommand } from '../clusters_util';
+import { I18N_AGENT_TOKEN } from '../constants';
+
+export default {
+ i18n: I18N_AGENT_TOKEN,
+ basicInstallPath: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'install-the-agent-into-the-cluster',
+ }),
+ advancedInstallPath: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'advanced-installation',
+ }),
+ components: {
+ GlAlert,
+ CodeBlock,
+ GlFormInputGroup,
+ GlLink,
+ GlSprintf,
+ ModalCopyButton,
+ },
+ inject: ['kasAddress'],
+ props: {
+ agentToken: {
+ required: true,
+ type: String,
+ },
+ modalId: {
+ required: true,
+ type: String,
+ },
+ },
+ computed: {
+ agentRegistrationCommand() {
+ return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <p>
+ <strong>{{ $options.i18n.tokenTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.tokenBody">
+ <template #link="{ content }">
+ <gl-link :href="$options.basicInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p>
+ <gl-alert
+ :title="$options.i18n.tokenSingleUseWarningTitle"
+ variant="warning"
+ :dismissible="false"
+ >
+ {{ $options.i18n.tokenSingleUseWarningBody }}
+ </gl-alert>
+ </p>
+
+ <p>
+ <gl-form-input-group readonly :value="agentToken" :select-on-click="true">
+ <template #append>
+ <modal-copy-button
+ :text="agentToken"
+ :title="$options.i18n.copyToken"
+ :modal-id="modalId"
+ />
+ </template>
+ </gl-form-input-group>
+ </p>
+
+ <p>
+ <strong>{{ $options.i18n.basicInstallTitle }}</strong>
+ </p>
+
+ <p>
+ {{ $options.i18n.basicInstallBody }}
+ </p>
+
+ <p class="gl-display-flex gl-align-items-flex-start">
+ <code-block class="gl-w-full" :code="agentRegistrationCommand" />
+ <modal-copy-button
+ :title="$options.i18n.copyCommand"
+ :text="agentRegistrationCommand"
+ :modal-id="modalId"
+ />
+ </p>
+
+ <p>
+ <strong>{{ $options.i18n.advancedInstallTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.advancedInstallBody">
+ <template #link="{ content }">
+ <gl-link :href="$options.advancedInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index bf096f53e9d..70b9b8ac3c9 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -116,9 +116,6 @@ export default {
},
},
methods: {
- reloadAgents() {
- this.$apollo.queries.agents.refetch();
- },
nextPage() {
this.cursor = {
first: MAX_LIST_COUNT,
diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
index 1630d0d5c92..662cf2a7e36 100644
--- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
+++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
@@ -1,5 +1,11 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlSprintf,
+} from '@gitlab/ui';
import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants';
export default {
@@ -8,6 +14,9 @@ export default {
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlSprintf,
},
props: {
isRegistering: {
@@ -22,6 +31,7 @@ export default {
data() {
return {
selectedAgent: null,
+ searchTerm: '',
};
},
computed: {
@@ -34,22 +44,45 @@ export default {
return this.selectedAgent;
},
+ shouldRenderCreateButton() {
+ return this.searchTerm && !this.availableAgents.includes(this.searchTerm);
+ },
+ filteredResults() {
+ const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.availableAgents.filter((resultString) =>
+ resultString.toLowerCase().includes(lowerCasedSearchTerm),
+ );
+ },
},
methods: {
selectAgent(agent) {
this.$emit('agentSelected', agent);
this.selectedAgent = agent;
+ this.clearSearch();
},
isSelected(agent) {
return this.selectedAgent === agent;
},
+ clearSearch() {
+ this.searchTerm = '';
+ },
+ focusSearch() {
+ this.$refs.searchInput.focusInput();
+ },
+ handleShow() {
+ this.clearSearch();
+ this.focusSearch();
+ },
},
};
</script>
<template>
- <gl-dropdown :text="dropdownText" :loading="isRegistering">
+ <gl-dropdown :text="dropdownText" :loading="isRegistering" @shown="handleShow">
+ <template #header>
+ <gl-search-box-by-type ref="searchInput" v-model.trim="searchTerm" />
+ </template>
<gl-dropdown-item
- v-for="agent in availableAgents"
+ v-for="agent in filteredResults"
:key="agent"
:is-checked="isSelected(agent)"
is-check-item
@@ -57,5 +90,16 @@ export default {
>
{{ agent }}
</gl-dropdown-item>
+ <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{
+ $options.i18n.noResults
+ }}</gl-dropdown-item>
+ <template v-if="shouldRenderCreateButton">
+ <gl-dropdown-divider />
+ <gl-dropdown-item data-testid="create-config-button" @click="selectAgent(searchTerm)">
+ <gl-sprintf :message="$options.i18n.createButton">
+ <template #searchTerm>{{ searchTerm }}</template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 7fb3aa3ff7e..59cfdde731d 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -6,7 +6,7 @@ import {
GlPagination,
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlSprintf,
- GlTable,
+ GlTableLite,
GlTooltipDirective,
} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
@@ -27,7 +27,7 @@ export default {
GlPagination,
GlSkeletonLoading,
GlSprintf,
- GlTable,
+ GlTableLite,
NodeErrorHelpText,
ClustersEmptyState,
},
@@ -229,7 +229,7 @@ export default {
<section v-else>
<ancestor-notice />
- <gl-table
+ <gl-table-lite
v-if="hasClusters"
:items="clusters"
:fields="fields"
@@ -326,7 +326,7 @@ export default {
{{ value }}
</gl-badge>
</template>
- </gl-table>
+ </gl-table-lite>
<clusters-empty-state v-else :is-child-component="isChildComponent" />
diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
index 5b8dc74b84f..ccb973f1eb8 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlButton,
GlDropdown,
GlDropdownItem,
GlModalDirective,
@@ -14,6 +15,7 @@ export default {
i18n: CLUSTERS_ACTIONS,
INSTALL_AGENT_MODAL_ID,
components: {
+ GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
@@ -23,11 +25,27 @@ export default {
GlModalDirective,
GlTooltip: GlTooltipDirective,
},
- inject: ['newClusterPath', 'addClusterPath', 'canAddCluster'],
+ inject: [
+ 'newClusterPath',
+ 'addClusterPath',
+ 'canAddCluster',
+ 'displayClusterAgents',
+ 'certificateBasedClustersEnabled',
+ ],
computed: {
tooltip() {
- const { connectWithAgent, dropdownDisabledHint } = this.$options.i18n;
- return this.canAddCluster ? connectWithAgent : dropdownDisabledHint;
+ const { connectWithAgent, connectExistingCluster, dropdownDisabledHint } = this.$options.i18n;
+
+ if (!this.canAddCluster) {
+ return dropdownDisabledHint;
+ } else if (this.displayClusterAgents) {
+ return connectWithAgent;
+ }
+
+ return connectExistingCluster;
+ },
+ shouldTriggerModal() {
+ return this.canAddCluster && this.displayClusterAgents;
},
},
};
@@ -36,25 +54,29 @@ export default {
<template>
<div class="nav-controls gl-ml-auto">
<gl-dropdown
+ v-if="certificateBasedClustersEnabled"
ref="dropdown"
- v-gl-modal-directive="canAddCluster && $options.INSTALL_AGENT_MODAL_ID"
+ v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID"
v-gl-tooltip="tooltip"
category="primary"
variant="confirm"
:text="$options.i18n.actionsButton"
:disabled="!canAddCluster"
- split
+ :split="displayClusterAgents"
right
>
- <gl-dropdown-section-header>{{ $options.i18n.agent }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
- data-testid="connect-new-agent-link"
- >
- {{ $options.i18n.connectWithAgent }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
- <gl-dropdown-section-header>{{ $options.i18n.certificate }}</gl-dropdown-section-header>
+ <template v-if="displayClusterAgents">
+ <gl-dropdown-section-header>{{ $options.i18n.agent }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ data-testid="connect-new-agent-link"
+ >
+ {{ $options.i18n.connectWithAgent }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-section-header>{{ $options.i18n.certificate }}</gl-dropdown-section-header>
+ </template>
+
<gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop>
{{ $options.i18n.createNewCluster }}
</gl-dropdown-item>
@@ -62,5 +84,15 @@ export default {
{{ $options.i18n.connectExistingCluster }}
</gl-dropdown-item>
</gl-dropdown>
+ <gl-button
+ v-else
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ v-gl-tooltip="tooltip"
+ :disabled="!canAddCluster"
+ category="primary"
+ variant="confirm"
+ >
+ {{ $options.i18n.connectWithAgent }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
index ce601de57bd..76bec05cfc7 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
@@ -13,7 +13,7 @@ export default {
GlSprintf,
GlAlert,
},
- inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'],
+ inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'addClusterPath'],
props: {
isChildComponent: {
default: false,
@@ -57,7 +57,7 @@ export default {
category="primary"
variant="confirm"
:disabled="!canAddCluster"
- :href="newClusterPath"
+ :href="addClusterPath"
>
{{ $options.i18n.buttonText }}
</gl-button>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
index 7dd5ece9b8e..aab6d3dc1f0 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
@@ -3,11 +3,13 @@ import { GlTabs, GlTab } from '@gitlab/ui';
import Tracking from '~/tracking';
import {
CLUSTERS_TABS,
+ CERTIFICATE_TAB,
MAX_CLUSTERS_LIST,
MAX_LIST_COUNT,
AGENT,
EVENT_LABEL_TABS,
EVENT_ACTIONS_CHANGE,
+ AGENT_TAB,
} from '../constants';
import Agents from './agents.vue';
import InstallAgentModal from './install_agent_modal.vue';
@@ -27,8 +29,8 @@ export default {
Agents,
InstallAgentModal,
},
- CLUSTERS_TABS,
mixins: [trackingMixin],
+ inject: ['displayClusterAgents', 'certificateBasedClustersEnabled'],
props: {
defaultBranchName: {
default: '.noBranch',
@@ -42,13 +44,30 @@ export default {
maxAgents: MAX_CLUSTERS_LIST,
};
},
+ computed: {
+ availableTabs() {
+ const clusterTabs = this.displayClusterAgents ? CLUSTERS_TABS : [CERTIFICATE_TAB];
+ return this.certificateBasedClustersEnabled ? clusterTabs : [AGENT_TAB];
+ },
+ },
+ watch: {
+ selectedTabIndex: {
+ handler(val) {
+ this.onTabChange(val);
+ },
+ immediate: true,
+ },
+ },
methods: {
- onTabChange(tabName) {
- this.selectedTabIndex = CLUSTERS_TABS.findIndex((tab) => tab.queryParamValue === tabName);
- this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST;
+ setSelectedTab(tabName) {
+ this.selectedTabIndex = this.availableTabs.findIndex(
+ (tab) => tab.queryParamValue === tabName,
+ );
},
- trackTabChange(tab) {
- const tabName = CLUSTERS_TABS[tab].queryParamValue;
+ onTabChange(tab) {
+ const tabName = this.availableTabs[tab].queryParamValue;
+
+ this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST;
this.track(EVENT_ACTIONS_CHANGE, { property: tabName });
},
},
@@ -61,10 +80,9 @@ export default {
sync-active-tab-with-query-params
nav-class="gl-flex-grow-1 gl-align-items-center"
lazy
- @input="trackTabChange"
>
<gl-tab
- v-for="(tab, idx) in $options.CLUSTERS_TABS"
+ v-for="(tab, idx) in availableTabs"
:key="idx"
:title="tab.title"
:query-param-value="tab.queryParamValue"
@@ -74,7 +92,7 @@ export default {
:is="tab.component"
:default-branch-name="defaultBranchName"
data-testid="clusters-tab-component"
- @changeTab="onTabChange"
+ @changeTab="setSelectedTab"
/>
</gl-tab>
diff --git a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
index 6588d304d5c..6f2c353a67b 100644
--- a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
+++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
@@ -116,7 +116,7 @@ export default {
this.$toast.show(this.error || successMessage);
- this.$refs.modal.hide();
+ this.$refs.modal?.hide();
}
},
deleteAgentMutation() {
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
index 8fc0a66cd7e..ae0affe4c8b 100644
--- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -1,18 +1,7 @@
<script>
-import {
- GlAlert,
- GlButton,
- GlFormGroup,
- GlFormInputGroup,
- GlLink,
- GlModal,
- GlSprintf,
-} from '@gitlab/ui';
+import { GlAlert, GlButton, GlFormGroup, GlLink, GlModal, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import CodeBlock from '~/vue_shared/components/code_block.vue';
import Tracking from '~/tracking';
-import { generateAgentRegistrationCommand } from '../clusters_util';
import {
INSTALL_AGENT_MODAL_ID,
I18N_AGENT_MODAL,
@@ -30,39 +19,32 @@ import createAgentToken from '../graphql/mutations/create_agent_token.mutation.g
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql';
import AvailableAgentsDropdown from './available_agents_dropdown.vue';
+import AgentToken from './agent_token.vue';
const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL });
export default {
modalId: INSTALL_AGENT_MODAL_ID,
+ i18n: I18N_AGENT_MODAL,
EVENT_ACTIONS_OPEN,
EVENT_ACTIONS_CLICK,
EVENT_LABEL_MODAL,
- basicInstallPath: helpPagePath('user/clusters/agent/install/index', {
- anchor: 'install-the-agent-into-the-cluster',
- }),
- advancedInstallPath: helpPagePath('user/clusters/agent/install/index', {
- anchor: 'advanced-installation',
- }),
enableKasPath: helpPagePath('administration/clusters/kas'),
- installAgentPath: helpPagePath('user/clusters/agent/install/index'),
registerAgentPath: helpPagePath('user/clusters/agent/install/index', {
anchor: 'register-an-agent-with-gitlab',
}),
components: {
AvailableAgentsDropdown,
- CodeBlock,
+ AgentToken,
GlAlert,
GlButton,
GlFormGroup,
- GlFormInputGroup,
GlLink,
GlModal,
GlSprintf,
- ModalCopyButton,
},
mixins: [trackingMixin],
- inject: ['projectPath', 'kasAddress', 'emptyStateImage'],
+ inject: ['projectPath', 'emptyStateImage'],
props: {
defaultBranchName: {
default: '.noBranch',
@@ -109,13 +91,10 @@ export default {
return !this.registering && this.agentName !== null;
},
canCancel() {
- return !this.registered && !this.registering && this.isAgentRegistrationModal;
+ return !this.registered && !this.registering && !this.kasDisabled;
},
canRegister() {
- return !this.registered && this.isAgentRegistrationModal;
- },
- agentRegistrationCommand() {
- return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
+ return !this.registered && !this.kasDisabled;
},
getAgentsQueryVariables() {
return {
@@ -125,32 +104,20 @@ export default {
projectPath: this.projectPath,
};
},
- i18n() {
- return I18N_AGENT_MODAL[this.modalType];
- },
+
repositoryPath() {
return `/${this.projectPath}`;
},
modalType() {
- return !this.availableAgents?.length && !this.registered
- ? MODAL_TYPE_EMPTY
- : MODAL_TYPE_REGISTER;
+ return this.kasDisabled ? MODAL_TYPE_EMPTY : MODAL_TYPE_REGISTER;
},
modalSize() {
- return this.isEmptyStateModal ? 'sm' : 'md';
- },
- isEmptyStateModal() {
- return this.modalType === MODAL_TYPE_EMPTY;
- },
- isAgentRegistrationModal() {
- return this.modalType === MODAL_TYPE_REGISTER;
- },
- isKasEnabledInEmptyStateModal() {
- return this.isEmptyStateModal && !this.kasDisabled;
+ return this.kasDisabled ? 'sm' : 'md';
},
},
methods: {
setAgentName(name) {
+ this.error = null;
this.agentName = name;
this.track(EVENT_ACTIONS_SELECT);
},
@@ -194,13 +161,13 @@ export default {
return createClusterAgent;
});
},
- createAgentTokenMutation(agendId) {
+ createAgentTokenMutation(agentId) {
return this.$apollo
.mutate({
mutation: createAgentToken,
variables: {
input: {
- clusterAgentId: agendId,
+ clusterAgentId: agentId,
name: this.agentName,
},
},
@@ -244,7 +211,7 @@ export default {
if (error) {
this.error = error.message;
} else {
- this.error = this.i18n.unknownError;
+ this.error = this.$options.i18n.unknownError;
}
} finally {
this.registering = false;
@@ -258,22 +225,21 @@ export default {
<gl-modal
ref="modal"
:modal-id="$options.modalId"
- :title="i18n.modalTitle"
+ :title="$options.i18n.modalTitle"
:size="modalSize"
static
lazy
@hidden="resetModal"
@show="track($options.EVENT_ACTIONS_OPEN, { property: modalType })"
>
- <template v-if="isAgentRegistrationModal">
+ <template v-if="!kasDisabled">
<template v-if="!registered">
- <p>
- <strong>{{ i18n.selectAgentTitle }}</strong>
- </p>
-
- <p class="gl-mb-0">{{ i18n.selectAgentBody }}</p>
- <p>
- <gl-link :href="$options.registerAgentPath"> {{ i18n.learnMoreLink }}</gl-link>
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.modalBody">
+ <template #link="{ content }">
+ <gl-link :href="repositoryPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</p>
<form>
@@ -287,90 +253,36 @@ export default {
</gl-form-group>
</form>
- <p v-if="error">
- <gl-alert :title="i18n.registrationErrorTitle" variant="danger" :dismissible="false">
- {{ error }}
- </gl-alert>
- </p>
- </template>
-
- <template v-else>
- <p>
- <strong>{{ i18n.tokenTitle }}</strong>
- </p>
-
<p>
- <gl-sprintf :message="i18n.tokenBody">
- <template #link="{ content }">
- <gl-link :href="$options.basicInstallPath" target="_blank"> {{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ <gl-link :href="$options.registerAgentPath"> {{ $options.i18n.learnMoreLink }}</gl-link>
</p>
- <p>
- <gl-alert :title="i18n.tokenSingleUseWarningTitle" variant="warning" :dismissible="false">
- {{ i18n.tokenSingleUseWarningBody }}
+ <p v-if="error">
+ <gl-alert
+ :title="$options.i18n.registrationErrorTitle"
+ variant="danger"
+ :dismissible="false"
+ >
+ {{ error }}
</gl-alert>
</p>
-
- <p>
- <gl-form-input-group readonly :value="agentToken" :select-on-click="true">
- <template #append>
- <modal-copy-button
- :text="agentToken"
- :title="i18n.copyToken"
- :modal-id="$options.modalId"
- />
- </template>
- </gl-form-input-group>
- </p>
-
- <p>
- <strong>{{ i18n.basicInstallTitle }}</strong>
- </p>
-
- <p>
- {{ i18n.basicInstallBody }}
- </p>
-
- <p>
- <code-block :code="agentRegistrationCommand" />
- </p>
-
- <p>
- <strong>{{ i18n.advancedInstallTitle }}</strong>
- </p>
-
- <p>
- <gl-sprintf :message="i18n.advancedInstallBody">
- <template #link="{ content }">
- <gl-link :href="$options.advancedInstallPath" target="_blank"> {{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
</template>
+
+ <agent-token v-else :agent-token="agentToken" :modal-id="$options.modalId" />
</template>
<template v-else>
<div class="gl-text-center gl-mb-5">
- <img :alt="i18n.altText" :src="emptyStateImage" height="100" />
+ <img :alt="$options.i18n.altText" :src="emptyStateImage" height="100" />
</div>
<p v-if="kasDisabled">
- <gl-sprintf :message="i18n.enableKasText">
+ <gl-sprintf :message="$options.i18n.enableKasText">
<template #link="{ content }">
<gl-link :href="$options.enableKasPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
-
- <p v-else>
- <gl-sprintf :message="i18n.modalBody">
- <template #link="{ content }">
- <gl-link :href="$options.installAgentPath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
</template>
<template #modal-footer>
@@ -382,7 +294,7 @@ export default {
:data-track-label="$options.EVENT_LABEL_MODAL"
data-track-property="close"
@click="closeModal"
- >{{ i18n.close }}
+ >{{ $options.i18n.close }}
</gl-button>
<gl-button
@@ -391,7 +303,7 @@ export default {
:data-track-label="$options.EVENT_LABEL_MODAL"
data-track-property="cancel"
@click="closeModal"
- >{{ i18n.cancel }}
+ >{{ $options.i18n.cancel }}
</gl-button>
<gl-button
@@ -403,25 +315,16 @@ export default {
:data-track-label="$options.EVENT_LABEL_MODAL"
data-track-property="register"
@click="registerAgent"
- >{{ i18n.registerAgentButton }}
+ >{{ $options.i18n.registerAgentButton }}
</gl-button>
<gl-button
- v-if="isEmptyStateModal"
+ v-if="kasDisabled"
:data-track-action="$options.EVENT_ACTIONS_CLICK"
:data-track-label="$options.EVENT_LABEL_MODAL"
data-track-property="done"
@click="closeModal"
- >{{ i18n.done }}
- </gl-button>
-
- <gl-button
- v-if="isKasEnabledInEmptyStateModal"
- :href="repositoryPath"
- variant="confirm"
- category="primary"
- data-testid="agent-primary-button"
- >{{ i18n.primaryButton }}
+ >{{ $options.i18n.close }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 5cf6fd050a1..c914ee518b2 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -75,74 +75,74 @@ export const I18N_AGENT_TABLE = {
neverConnectedText: s__('ClusterAgents|Never'),
versionMismatchTitle: s__('ClusterAgents|Agent version mismatch'),
versionMismatchText: s__(
- "ClusterAgents|The Agent version do not match each other across your cluster's pods. This can happen when a new Agent version was just deployed and Kubernetes is shutting down the old pods.",
+ "ClusterAgents|The agent version do not match each other across your cluster's pods. This can happen when a new agent version was just deployed and Kubernetes is shutting down the old pods.",
),
versionOutdatedTitle: s__('ClusterAgents|Agent version update required'),
versionOutdatedText: s__(
- 'ClusterAgents|Your Agent version is out of sync with your GitLab version (v%{version}), which might cause compatibility problems. Update the Agent installed on your cluster to the most recent version.',
+ 'ClusterAgents|Your agent version is out of sync with your GitLab version (v%{version}), which might cause compatibility problems. Update the agent installed on your cluster to the most recent version.',
),
versionMismatchOutdatedTitle: s__('ClusterAgents|Agent version mismatch and update'),
- viewDocsText: s__('ClusterAgents|How to update the Agent?'),
+ viewDocsText: s__('ClusterAgents|How to update an agent?'),
+ defaultConfigText: s__('ClusterAgents|Default configuration'),
+ defaultConfigTooltip: s__('ClusterAgents|What is default configuration?'),
};
-export const I18N_AGENT_MODAL = {
- agent_registration: {
- registerAgentButton: s__('ClusterAgents|Register'),
- close: __('Close'),
- cancel: __('Cancel'),
-
- modalTitle: s__('ClusterAgents|Connect a cluster through the Agent'),
- selectAgentTitle: s__('ClusterAgents|Select an agent to register with GitLab'),
- selectAgentBody: s__(
- 'ClusterAgents|Register an agent to generate a token that will be used to install the agent on your cluster in the next step.',
- ),
- learnMoreLink: s__('ClusterAgents|How to register an agent?'),
+export const I18N_AGENT_TOKEN = {
+ copyToken: s__('ClusterAgents|Copy token'),
+ copyCommand: s__('ClusterAgents|Copy command'),
+ tokenTitle: s__('ClusterAgents|Registration token'),
- copyToken: s__('ClusterAgents|Copy token'),
- tokenTitle: s__('ClusterAgents|Registration token'),
- tokenBody: s__(
- `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`,
- ),
+ tokenBody: s__(
+ `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`,
+ ),
+ tokenSingleUseWarningTitle: s__(
+ 'ClusterAgents|You cannot see this token again after you close this window.',
+ ),
+ tokenSingleUseWarningBody: s__(
+ `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`,
+ ),
- tokenSingleUseWarningTitle: s__(
- 'ClusterAgents|You cannot see this token again after you close this window.',
- ),
- tokenSingleUseWarningBody: s__(
- `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`,
- ),
+ basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
+ basicInstallBody: __(
+ `Open a CLI and connect to the cluster you want to install the agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`,
+ ),
- basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
- basicInstallBody: __(
- `Open a CLI and connect to the cluster you want to install the agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`,
- ),
+ advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'),
+ advancedInstallBody: s__(
+ 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.',
+ ),
+};
- advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'),
- advancedInstallBody: s__(
- 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.',
- ),
+export const I18N_AGENT_MODAL = {
+ registerAgentButton: s__('ClusterAgents|Register'),
+ close: __('Close'),
+ cancel: __('Cancel'),
- registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'),
- unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
- },
- empty_state: {
- modalTitle: s__('ClusterAgents|Connect your cluster through the Agent'),
- modalBody: s__(
- "ClusterAgents|To install a new agent, first add the agent's configuration file to this repository. %{linkStart}Learn more about installing GitLab Agent.%{linkEnd}",
- ),
- enableKasText: s__(
- "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.",
- ),
- altText: s__('ClusterAgents|GitLab Agent for Kubernetes'),
- primaryButton: s__('ClusterAgents|Go to the repository files'),
- done: __('Cancel'),
- },
+ modalTitle: s__('ClusterAgents|Connect a cluster through an agent'),
+ modalBody: s__(
+ 'ClusterAgents|Add an agent configuration file to %{linkStart}this repository%{linkEnd} and select it, or create a new one to register with GitLab:',
+ ),
+ enableKasText: s__(
+ "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.",
+ ),
+ altText: s__('ClusterAgents|GitLab Agent for Kubernetes'),
+ learnMoreLink: s__('ClusterAgents|How do I register an agent?'),
+ copyToken: s__('ClusterAgents|Copy token'),
+ tokenTitle: s__('ClusterAgents|Registration token'),
+ tokenBody: s__(
+ `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`,
+ ),
+ registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'),
+ unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
};
export const KAS_DISABLED_ERROR = 'Gitlab::Kas::Client::ConfigurationError';
export const I18N_AVAILABLE_AGENTS_DROPDOWN = {
- selectAgent: s__('ClusterAgents|Select an agent'),
- registeringAgent: s__('ClusterAgents|Registering Agent'),
+ selectAgent: s__('ClusterAgents|Select an agent or enter a name to create new'),
+ registeringAgent: s__('ClusterAgents|Registering agent'),
+ noResults: __('No matching results'),
+ createButton: s__('ClusterAgents|Create agent: %{searchTerm}'),
};
export const AGENT_STATUSES = {
@@ -197,8 +197,8 @@ export const I18N_CLUSTERS_EMPTY_STATE = {
export const AGENT_CARD_INFO = {
tabName: 'agent',
- title: sprintf(s__('ClusterAgents|%{number} of %{total} Agents')),
- emptyTitle: s__('ClusterAgents|No Agents'),
+ title: sprintf(s__('ClusterAgents|%{number} of %{total} agents')),
+ emptyTitle: s__('ClusterAgents|No agents'),
tooltip: {
label: s__('ClusterAgents|Recommended'),
title: s__('ClusterAgents|GitLab Agent'),
@@ -209,7 +209,7 @@ export const AGENT_CARD_INFO = {
),
link: helpPagePath('user/clusters/agent/index'),
},
- actionText: s__('ClusterAgents|Install new Agent'),
+ actionText: s__('ClusterAgents|Install a new agent'),
footerText: sprintf(s__('ClusterAgents|View all %{number} agents')),
installAgentDisabledHint: s__(
'ClusterAgents|Requires a Maintainer or greater role to install new agents',
@@ -232,28 +232,29 @@ export const CERTIFICATE_BASED_CARD_INFO = {
export const MAX_CLUSTERS_LIST = 6;
-export const CLUSTERS_TABS = [
- {
- title: s__('ClusterAgents|All'),
- component: 'ClustersViewAll',
- queryParamValue: 'all',
- },
- {
- title: s__('ClusterAgents|Agent'),
- component: 'agents',
- queryParamValue: 'agent',
- },
- {
- title: s__('ClusterAgents|Certificate'),
- component: 'clusters',
- queryParamValue: 'certificate_based',
- },
-];
+export const ALL_TAB = {
+ title: s__('ClusterAgents|All'),
+ component: 'ClustersViewAll',
+ queryParamValue: 'all',
+};
+
+export const AGENT_TAB = {
+ title: s__('ClusterAgents|Agent'),
+ component: 'agents',
+ queryParamValue: 'agent',
+};
+export const CERTIFICATE_TAB = {
+ title: s__('ClusterAgents|Certificate'),
+ component: 'clusters',
+ queryParamValue: 'certificate_based',
+};
+
+export const CLUSTERS_TABS = [ALL_TAB, AGENT_TAB, CERTIFICATE_TAB];
export const CLUSTERS_ACTIONS = {
actionsButton: s__('ClusterAgents|Actions'),
createNewCluster: s__('ClusterAgents|Create a new cluster'),
- connectWithAgent: s__('ClusterAgents|Connect with Agent'),
+ connectWithAgent: s__('ClusterAgents|Connect with an agent'),
connectExistingCluster: s__('ClusterAgents|Connect with a certificate'),
agent: s__('ClusterAgents|Agent'),
certificate: s__('ClusterAgents|Certificate'),
diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js
index 6476b7a6c2f..e68f6a378c0 100644
--- a/app/assets/javascripts/clusters_list/graphql/cache_update.js
+++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js
@@ -1,5 +1,4 @@
import produce from 'immer';
-import { getAgentConfigPath } from '../clusters_util';
export const hasErrors = ({ errors = [] }) => errors?.length;
@@ -12,17 +11,8 @@ export function addAgentToStore(store, createClusterAgent, query, variables) {
});
const data = produce(sourceData, (draftData) => {
- const configuration = {
- id: clusterAgent.id,
- name: clusterAgent.name,
- path: getAgentConfigPath(clusterAgent.name),
- webPath: clusterAgent.webPath,
- __typename: 'TreeEntry',
- };
-
draftData.project.clusterAgents.nodes.push(clusterAgent);
draftData.project.clusterAgents.count += 1;
- draftData.project.repository.tree.trees.nodes.push(configuration);
});
store.writeQuery({
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
index f8efb6683f6..7743ffba5de 100644
--- a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
@@ -7,9 +7,7 @@ query getAgents(
$first: Int
$last: Int
$afterAgent: String
- $afterTree: String
$beforeAgent: String
- $beforeTree: String
) {
project(fullPath: $projectPath) {
id
@@ -27,17 +25,13 @@ query getAgents(
repository {
tree(path: ".gitlab/agents", ref: $defaultBranchName) {
- trees(first: $first, last: $last, after: $afterTree, before: $beforeTree) {
+ trees {
nodes {
id
name
path
webPath
}
-
- pageInfo {
- ...PageInfo
- }
}
}
}
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index 6148483dcb0..27eebc9d891 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -1,13 +1,63 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import loadClusters from './load_clusters';
-import loadMainView from './load_main_view';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import createDefaultClient from '~/lib/graphql';
+import ClustersMainView from './components/clusters_main_view.vue';
+import { createStore } from './store';
Vue.use(GlToast);
Vue.use(VueApollo);
export default () => {
- loadClusters(Vue);
- loadMainView(Vue, VueApollo);
+ const el = document.querySelector('.js-clusters-main-view');
+
+ if (!el) {
+ return null;
+ }
+
+ const defaultClient = createDefaultClient();
+
+ const {
+ emptyStateImage,
+ defaultBranchName,
+ projectPath,
+ kasAddress,
+ newClusterPath,
+ addClusterPath,
+ emptyStateHelpText,
+ clustersEmptyStateImage,
+ canAddCluster,
+ canAdminCluster,
+ gitlabVersion,
+ displayClusterAgents,
+ certificateBasedClustersEnabled,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({ defaultClient }),
+ provide: {
+ emptyStateImage,
+ projectPath,
+ kasAddress,
+ newClusterPath,
+ addClusterPath,
+ emptyStateHelpText,
+ clustersEmptyStateImage,
+ canAddCluster: parseBoolean(canAddCluster),
+ canAdminCluster: parseBoolean(canAdminCluster),
+ gitlabVersion,
+ displayClusterAgents: parseBoolean(displayClusterAgents),
+ certificateBasedClustersEnabled: parseBoolean(certificateBasedClustersEnabled),
+ },
+ store: createStore(el.dataset),
+ render(createElement) {
+ return createElement(ClustersMainView, {
+ props: {
+ defaultBranchName,
+ },
+ });
+ },
+ });
};
diff --git a/app/assets/javascripts/clusters_list/load_clusters.js b/app/assets/javascripts/clusters_list/load_clusters.js
deleted file mode 100644
index 1bb3ea546b2..00000000000
--- a/app/assets/javascripts/clusters_list/load_clusters.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Clusters from './components/clusters.vue';
-import { createStore } from './store';
-
-export default (Vue) => {
- const el = document.querySelector('#js-clusters-list-app');
-
- if (!el) {
- return null;
- }
-
- const { emptyStateHelpText, newClusterPath, clustersEmptyStateImage } = el.dataset;
-
- return new Vue({
- el,
- provide: {
- emptyStateHelpText,
- newClusterPath,
- clustersEmptyStateImage,
- },
- store: createStore(el.dataset),
- render(createElement) {
- return createElement(Clusters);
- },
- });
-};
diff --git a/app/assets/javascripts/clusters_list/load_main_view.js b/app/assets/javascripts/clusters_list/load_main_view.js
deleted file mode 100644
index d52b1d4a64d..00000000000
--- a/app/assets/javascripts/clusters_list/load_main_view.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import createDefaultClient from '~/lib/graphql';
-import ClustersMainView from './components/clusters_main_view.vue';
-import { createStore } from './store';
-
-Vue.use(VueApollo);
-
-export default () => {
- const el = document.querySelector('.js-clusters-main-view');
-
- if (!el) {
- return null;
- }
-
- const defaultClient = createDefaultClient();
-
- const {
- emptyStateImage,
- defaultBranchName,
- projectPath,
- kasAddress,
- newClusterPath,
- addClusterPath,
- emptyStateHelpText,
- clustersEmptyStateImage,
- canAddCluster,
- canAdminCluster,
- gitlabVersion,
- } = el.dataset;
-
- return new Vue({
- el,
- apolloProvider: new VueApollo({ defaultClient }),
- provide: {
- emptyStateImage,
- projectPath,
- kasAddress,
- newClusterPath,
- addClusterPath,
- emptyStateHelpText,
- clustersEmptyStateImage,
- canAddCluster: parseBoolean(canAddCluster),
- canAdminCluster: parseBoolean(canAdminCluster),
- gitlabVersion,
- },
- store: createStore(el.dataset),
- render(createElement) {
- return createElement(ClustersMainView, {
- props: {
- defaultBranchName,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue
index d38b38947b6..5c77f087d63 100644
--- a/app/assets/javascripts/code_navigation/components/app.vue
+++ b/app/assets/javascripts/code_navigation/components/app.vue
@@ -7,6 +7,23 @@ export default {
components: {
Popover,
},
+ props: {
+ codeNavigationPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ blobPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ pathPrefix: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
computed: {
...mapState([
'currentDefinition',
@@ -16,6 +33,14 @@ export default {
]),
},
mounted() {
+ if (this.codeNavigationPath && this.blobPath && this.pathPrefix) {
+ const initialData = {
+ blobs: [{ path: this.blobPath, codeNavigationPath: this.codeNavigationPath }],
+ definitionPathPrefix: this.pathPrefix,
+ };
+ this.setInitialData(initialData);
+ }
+
this.body = document.body;
eventHub.$on('showBlobInteractionZones', this.showBlobInteractionZones);
@@ -28,7 +53,7 @@ export default {
this.removeGlobalEventListeners();
},
methods: {
- ...mapActions(['fetchData', 'showDefinition', 'showBlobInteractionZones']),
+ ...mapActions(['fetchData', 'showDefinition', 'showBlobInteractionZones', 'setInitialData']),
addGlobalEventListeners() {
if (this.body) {
this.body.addEventListener('click', this.showDefinition);
diff --git a/app/assets/javascripts/code_quality_walkthrough/components/step.vue b/app/assets/javascripts/code_quality_walkthrough/components/step.vue
deleted file mode 100644
index 1a23c96b7d6..00000000000
--- a/app/assets/javascripts/code_quality_walkthrough/components/step.vue
+++ /dev/null
@@ -1,150 +0,0 @@
-<script>
-import { GlPopover, GlSprintf, GlButton, GlAlert } from '@gitlab/ui';
-import { STEPS, STEPSTATES } from '../constants';
-import {
- isWalkthroughEnabled,
- getExperimentSettings,
- setExperimentSettings,
- track,
-} from '../utils';
-
-export default {
- target: '#js-code-quality-walkthrough',
- components: {
- GlPopover,
- GlSprintf,
- GlButton,
- GlAlert,
- },
- props: {
- step: {
- type: String,
- required: true,
- },
- link: {
- type: String,
- required: false,
- default: null,
- },
- },
- data() {
- return {
- dismissedSettings: getExperimentSettings(),
- currentStep: STEPSTATES[this.step],
- };
- },
- computed: {
- isPopoverVisible() {
- return (
- [
- STEPS.commitCiFile,
- STEPS.runningPipeline,
- STEPS.successPipeline,
- STEPS.failedPipeline,
- ].includes(this.step) &&
- isWalkthroughEnabled() &&
- !this.isDismissed
- );
- },
- isAlertVisible() {
- return this.step === STEPS.troubleshootJob && isWalkthroughEnabled() && !this.isDismissed;
- },
- isDismissed() {
- return this.dismissedSettings[this.step];
- },
- title() {
- return this.currentStep?.title || '';
- },
- body() {
- return this.currentStep?.body || '';
- },
- buttonText() {
- return this.currentStep?.buttonText || '';
- },
- buttonLink() {
- return [STEPS.successPipeline, STEPS.failedPipeline].includes(this.step) ? this.link : '';
- },
- placement() {
- return this.currentStep?.placement || 'bottom';
- },
- offset() {
- return this.currentStep?.offset || 0;
- },
- },
- created() {
- this.trackDisplayed();
- },
- updated() {
- this.trackDisplayed();
- },
- methods: {
- onDismiss() {
- this.$set(this.dismissedSettings, this.step, true);
- setExperimentSettings(this.dismissedSettings);
- const action = [STEPS.successPipeline, STEPS.failedPipeline].includes(this.step)
- ? 'view_logs'
- : 'dismissed';
- this.trackAction(action);
- },
- trackDisplayed() {
- if (this.isPopoverVisible || this.isAlertVisible) {
- this.trackAction('displayed');
- }
- },
- trackAction(action) {
- track(`${this.step}_${action}`);
- },
- },
-};
-</script>
-
-<template>
- <div>
- <gl-popover
- v-if="isPopoverVisible"
- :key="step"
- :target="$options.target"
- :placement="placement"
- :offset="offset"
- show
- triggers="manual"
- container="viewport"
- >
- <template #title>
- <gl-sprintf :message="title">
- <template #emoji="{ content }">
- <gl-emoji class="gl-mr-2" :data-name="content"
- /></template>
- </gl-sprintf>
- </template>
- <gl-sprintf :message="body">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- <template #lineBreak>
- <div class="gl-mt-5"></div>
- </template>
- <template #emoji="{ content }">
- <gl-emoji :data-name="content" />
- </template>
- </gl-sprintf>
- <div class="gl-mt-2 gl-text-right">
- <gl-button category="tertiary" variant="link" :href="buttonLink" @click="onDismiss">
- {{ buttonText }}
- </gl-button>
- </div>
- </gl-popover>
- <gl-alert
- v-if="isAlertVisible"
- variant="tip"
- :title="title"
- :primary-button-text="buttonText"
- :primary-button-link="link"
- class="gl-my-5"
- @primaryAction="trackAction('clicked')"
- @dismiss="onDismiss"
- >
- {{ body }}
- </gl-alert>
- </div>
-</template>
diff --git a/app/assets/javascripts/code_quality_walkthrough/constants.js b/app/assets/javascripts/code_quality_walkthrough/constants.js
deleted file mode 100644
index 011df06b5cc..00000000000
--- a/app/assets/javascripts/code_quality_walkthrough/constants.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import { s__ } from '~/locale';
-
-export const EXPERIMENT_NAME = 'code_quality_walkthrough';
-
-export const STEPS = {
- commitCiFile: 'commit_ci_file',
- runningPipeline: 'running_pipeline',
- successPipeline: 'success_pipeline',
- failedPipeline: 'failed_pipeline',
- troubleshootJob: 'troubleshoot_job',
-};
-
-export const STEPSTATES = {
- [STEPS.commitCiFile]: {
- title: s__("codeQualityWalkthrough|Let's start by creating a new CI file."),
- body: s__(
- 'codeQualityWalkthrough|To begin with code quality, we first need to create a new CI file using our code editor. We added a code quality template in the code editor to help you get started %{emojiStart}wink%{emojiEnd} .%{lineBreak}Take some time to review the template, when you are ready, use the %{strongStart}commit changes%{strongEnd} button at the bottom of the page.',
- ),
- buttonText: s__('codeQualityWalkthrough|Got it'),
- placement: 'right',
- offset: 90,
- },
- [STEPS.runningPipeline]: {
- title: s__(
- 'codeQualityWalkthrough|Congrats! Your first pipeline is running %{emojiStart}zap%{emojiEnd}',
- ),
- body: s__(
- "codeQualityWalkthrough|Your pipeline can take a few minutes to run. If you enabled email notifications, you'll receive an email with your pipeline status. In the meantime, why don't you get some coffee? You earned it!",
- ),
- buttonText: s__('codeQualityWalkthrough|Got it'),
- offset: 97,
- },
- [STEPS.successPipeline]: {
- title: s__(
- "codeQualityWalkthrough|Well done! You've just automated your code quality review. %{emojiStart}raised_hands%{emojiEnd}",
- ),
- body: s__(
- 'codeQualityWalkthrough|A code quality job will now run every time you or your team members commit changes to your project. You can view the results of the code quality job in the job logs.',
- ),
- buttonText: s__('codeQualityWalkthrough|View the logs'),
- offset: 98,
- },
- [STEPS.failedPipeline]: {
- title: s__(
- "codeQualityWalkthrough|Something went wrong. %{emojiStart}thinking%{emojiEnd} Let's fix it.",
- ),
- body: s__(
- "codeQualityWalkthrough|Your job failed. No worries - this happens. Let's view the logs, and see how we can fix it.",
- ),
- buttonText: s__('codeQualityWalkthrough|View the logs'),
- offset: 98,
- },
- [STEPS.troubleshootJob]: {
- title: s__('codeQualityWalkthrough|Troubleshoot your code quality job'),
- body: s__(
- 'codeQualityWalkthrough|Not sure how to fix your failed job? We have compiled some tips on how to troubleshoot code quality jobs in the documentation.',
- ),
- buttonText: s__('codeQualityWalkthrough|Read the documentation'),
- },
-};
-
-export const PIPELINE_STATUSES = {
- running: 'running',
- successWithWarnings: 'success-with-warnings',
- success: 'success',
- failed: 'failed',
-};
diff --git a/app/assets/javascripts/code_quality_walkthrough/index.js b/app/assets/javascripts/code_quality_walkthrough/index.js
deleted file mode 100644
index b0592b8a84b..00000000000
--- a/app/assets/javascripts/code_quality_walkthrough/index.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Vue from 'vue';
-import Step from './components/step.vue';
-
-export default (el) =>
- new Vue({
- el,
- render(createElement) {
- return createElement(Step, {
- props: {
- step: el.dataset.step,
- },
- });
- },
- });
diff --git a/app/assets/javascripts/code_quality_walkthrough/utils.js b/app/assets/javascripts/code_quality_walkthrough/utils.js
deleted file mode 100644
index 894ec9a171d..00000000000
--- a/app/assets/javascripts/code_quality_walkthrough/utils.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
-import { getExperimentData } from '~/experimentation/utils';
-import { setCookie, getCookie } from '~/lib/utils/common_utils';
-import { getParameterByName } from '~/lib/utils/url_utility';
-import Tracking from '~/tracking';
-import { EXPERIMENT_NAME } from './constants';
-
-export function getExperimentSettings() {
- return JSON.parse(getCookie(EXPERIMENT_NAME) || '{}');
-}
-
-export function setExperimentSettings(settings) {
- setCookie(EXPERIMENT_NAME, settings);
-}
-
-export function isWalkthroughEnabled() {
- return getParameterByName(EXPERIMENT_NAME);
-}
-
-export function track(action) {
- const { data } = getExperimentSettings();
-
- if (data) {
- Tracking.event(EXPERIMENT_NAME, action, {
- context: {
- schema: TRACKING_CONTEXT_SCHEMA,
- data,
- },
- });
- }
-}
-
-export function startCodeQualityWalkthrough() {
- const data = getExperimentData(EXPERIMENT_NAME);
-
- if (data) {
- setExperimentSettings({ data });
- }
-}
diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js
index 84ab728274f..784e9cb2faa 100644
--- a/app/assets/javascripts/commons/nav/user_merge_requests.js
+++ b/app/assets/javascripts/commons/nav/user_merge_requests.js
@@ -23,7 +23,18 @@ function updateReviewerMergeRequestCounts(newCount) {
function updateMergeRequestCounts(newCount) {
const mergeRequestsCountEl = document.querySelector('.js-merge-requests-count');
mergeRequestsCountEl.textContent = newCount.toLocaleString();
- mergeRequestsCountEl.classList.toggle('hidden', Number(newCount) === 0);
+ mergeRequestsCountEl.classList.toggle('gl-display-none', Number(newCount) === 0);
+}
+
+function updateAttentionRequestsCount(count) {
+ const attentionCountEl = document.querySelector('.js-attention-count');
+ attentionCountEl.textContent = count.toLocaleString();
+
+ if (Number(count) === 0) {
+ attentionCountEl.classList.replace('badge-warning', 'badge-neutral');
+ } else {
+ attentionCountEl.classList.replace('badge-neutral', 'badge-warning');
+ }
}
/**
@@ -32,14 +43,22 @@ function updateMergeRequestCounts(newCount) {
export function refreshUserMergeRequestCounts() {
return getUserCounts()
.then(({ data }) => {
+ const attentionRequestsEnabled = window.gon?.features?.mrAttentionRequests;
const assignedMergeRequests = data.assigned_merge_requests;
const reviewerMergeRequests = data.review_requested_merge_requests;
- const fullCount = assignedMergeRequests + reviewerMergeRequests;
+ const attentionRequests = data.attention_requests;
+ const fullCount = attentionRequestsEnabled
+ ? attentionRequests
+ : assignedMergeRequests + reviewerMergeRequests;
updateUserMergeRequestCounts(assignedMergeRequests);
updateReviewerMergeRequestCounts(reviewerMergeRequests);
updateMergeRequestCounts(fullCount);
broadcastCount(fullCount);
+
+ if (attentionRequestsEnabled) {
+ updateAttentionRequestsCount(attentionRequests);
+ }
})
.catch((ex) => {
console.error(ex); // eslint-disable-line no-console
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index a8405fe37c7..a942c9f1149 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,17 +1,16 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
-import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
import { createContentEditor } from '../services/create_content_editor';
import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
import FormattingBubbleMenu from './formatting_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
+import LoadingIndicator from './loading_indicator.vue';
export default {
components: {
- GlLoadingIcon,
+ LoadingIndicator,
ContentEditorAlert,
ContentEditorProvider,
TiptapEditorContent,
@@ -41,7 +40,6 @@ export default {
},
data() {
return {
- isLoadingContent: false,
focused: false,
};
},
@@ -55,25 +53,14 @@ export default {
extensions,
serializerConfig,
});
-
- this.contentEditor.on(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
- this.contentEditor.on(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
- this.contentEditor.on(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
+ },
+ mounted() {
this.$emit('initialized', this.contentEditor);
},
beforeDestroy() {
this.contentEditor.dispose();
- this.contentEditor.off(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
- this.contentEditor.off(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
- this.contentEditor.off(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
},
methods: {
- displayLoadingIndicator() {
- this.isLoadingContent = true;
- },
- hideLoadingIndicator() {
- this.isLoadingContent = false;
- },
focus() {
this.focused = true;
},
@@ -100,13 +87,11 @@ export default {
:class="{ 'is-focused': focused }"
>
<top-toolbar ref="toolbar" class="gl-mb-4" />
- <div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center">
- <gl-loading-icon size="sm" />
- </div>
- <template v-else>
+ <div class="gl-relative">
<formatting-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
- </template>
+ <loading-indicator />
+ </div>
</div>
</div>
</content-editor-provider>
diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
index 630aff9858f..cba3b627390 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_provider.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
@@ -8,6 +8,7 @@ export default {
return {
contentEditor,
+ eventHub: contentEditor.eventHub,
tiptapEditor: contentEditor.tiptapEditor,
};
},
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index 0604047a953..02de6470cf2 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -1,5 +1,11 @@
<script>
import { debounce } from 'lodash';
+import {
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+ ALERT_EVENT,
+} from '../constants';
export const tiptapToComponentMap = {
update: 'docUpdate',
@@ -7,30 +13,48 @@ export const tiptapToComponentMap = {
transaction: 'transaction',
focus: 'focus',
blur: 'blur',
- alert: 'alert',
};
+export const eventHubEvents = [
+ ALERT_EVENT,
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+];
+
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
export default {
- inject: ['tiptapEditor'],
+ inject: ['tiptapEditor', 'eventHub'],
created() {
this.disposables = [];
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
- const eventHandler = debounce((params) => this.handleTipTapEvent(tiptapEvent, params), 100);
+ const eventHandler = debounce(
+ (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params),
+ 100,
+ );
this.tiptapEditor?.on(tiptapEvent, eventHandler);
this.disposables.push(() => this.tiptapEditor?.off(tiptapEvent, eventHandler));
});
+
+ eventHubEvents.forEach((event) => {
+ const handler = (...params) => {
+ this.bubbleEvent(event, ...params);
+ };
+
+ this.eventHub.$on(event, handler);
+ this.disposables.push(() => this.eventHub?.$off(event, handler));
+ });
},
beforeDestroy() {
this.disposables.forEach((dispose) => dispose());
},
methods: {
- handleTipTapEvent(tiptapEvent, params) {
- this.$emit(getComponentEventName(tiptapEvent), params);
+ bubbleEvent(eventHubEvent, params) {
+ this.$emit(eventHubEvent, params);
},
},
render() {
diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue
new file mode 100644
index 00000000000..5b9383d6e11
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import EditorStateObserver from './editor_state_observer.vue';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ EditorStateObserver,
+ },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ methods: {
+ displayLoadingIndicator() {
+ this.isLoading = true;
+ },
+ hideLoadingIndicator() {
+ this.isLoading = false;
+ },
+ },
+};
+</script>
+<template>
+ <editor-state-observer
+ @loading="displayLoadingIndicator"
+ @loadingSuccess="hideLoadingIndicator"
+ @loadingError="hideLoadingIndicator"
+ >
+ <div
+ v-if="isLoading"
+ class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
+ >
+ <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
+ <gl-loading-icon size="md" />
+ </div>
+ </editor-state-observer>
+</template>
diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js
index 5e56078df01..a39a243ec6b 100644
--- a/app/assets/javascripts/content_editor/constants.js
+++ b/app/assets/javascripts/content_editor/constants.js
@@ -42,9 +42,10 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
},
];
-export const LOADING_CONTENT_EVENT = 'loadingContent';
+export const LOADING_CONTENT_EVENT = 'loading';
export const LOADING_SUCCESS_EVENT = 'loadingSuccess';
export const LOADING_ERROR_EVENT = 'loadingError';
+export const ALERT_EVENT = 'alert';
export const PARSE_HTML_PRIORITY_LOWEST = 1;
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
@@ -56,3 +57,4 @@ export const EXTENSION_PRIORITY_LOWER = 75;
* https://tiptap.dev/guide/custom-extensions/#priority
*/
export const EXTENSION_PRIORITY_DEFAULT = 100;
+export const EXTENSION_PRIORITY_HIGHEST = 200;
diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js
index 72df1d071d1..9634730f637 100644
--- a/app/assets/javascripts/content_editor/extensions/attachment.js
+++ b/app/assets/javascripts/content_editor/extensions/attachment.js
@@ -9,15 +9,22 @@ export default Extension.create({
return {
uploadsPath: null,
renderMarkdown: null,
+ eventHub: null,
};
},
addCommands() {
return {
uploadAttachment: ({ file }) => () => {
- const { uploadsPath, renderMarkdown } = this.options;
+ const { uploadsPath, renderMarkdown, eventHub } = this.options;
- return handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor });
+ return handleFileEvent({
+ file,
+ uploadsPath,
+ renderMarkdown,
+ editor: this.editor,
+ eventHub,
+ });
},
};
},
@@ -29,23 +36,25 @@ export default Extension.create({
key: new PluginKey('attachment'),
props: {
handlePaste: (_, event) => {
- const { uploadsPath, renderMarkdown } = this.options;
+ const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({
editor,
file: event.clipboardData.files[0],
uploadsPath,
renderMarkdown,
+ eventHub,
});
},
handleDrop: (_, event) => {
- const { uploadsPath, renderMarkdown } = this.options;
+ const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({
editor,
file: event.dataTransfer.files[0],
uploadsPath,
renderMarkdown,
+ eventHub,
});
},
},
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 9dc17fcd570..204ac07d401 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,5 +1,5 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
-import * as lowlight from 'lowlight';
+import { lowlight } from 'lowlight/lib/all';
const extractLanguage = (element) => element.getAttribute('lang');
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
new file mode 100644
index 00000000000..c349aa42a62
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -0,0 +1,86 @@
+import { Extension } from '@tiptap/core';
+import { Plugin, PluginKey } from 'prosemirror-state';
+import { __ } from '~/locale';
+import { VARIANT_DANGER } from '~/flash';
+import createMarkdownDeserializer from '../services/markdown_deserializer';
+import {
+ ALERT_EVENT,
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+ EXTENSION_PRIORITY_HIGHEST,
+} from '../constants';
+
+const TEXT_FORMAT = 'text/plain';
+const HTML_FORMAT = 'text/html';
+const VS_CODE_FORMAT = 'vscode-editor-data';
+
+export default Extension.create({
+ name: 'pasteMarkdown',
+ priority: EXTENSION_PRIORITY_HIGHEST,
+ addOptions() {
+ return {
+ renderMarkdown: null,
+ };
+ },
+ addCommands() {
+ return {
+ pasteMarkdown: (markdown) => () => {
+ const { editor, options } = this;
+ const { renderMarkdown, eventHub } = options;
+ const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
+
+ eventHub.$emit(LOADING_CONTENT_EVENT);
+
+ deserializer
+ .deserialize({ schema: editor.schema, content: markdown })
+ .then(({ document }) => {
+ if (!document) {
+ return;
+ }
+
+ const { state, view } = editor;
+ const { tr, selection } = state;
+
+ tr.replaceWith(selection.from - 1, selection.to, document.content);
+ view.dispatch(tr);
+ eventHub.$emit(LOADING_SUCCESS_EVENT);
+ })
+ .catch(() => {
+ eventHub.$emit(ALERT_EVENT, {
+ message: __('An error occurred while pasting text in the editor. Please try again.'),
+ variant: VARIANT_DANGER,
+ });
+ eventHub.$emit(LOADING_ERROR_EVENT);
+ });
+
+ return true;
+ },
+ };
+ },
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: new PluginKey('pasteMarkdown'),
+ props: {
+ handlePaste: (_, event) => {
+ const { clipboardData } = event;
+ const content = clipboardData.getData(TEXT_FORMAT);
+ const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
+ const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
+ const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
+ const language = vsCodeMeta.mode;
+
+ if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) {
+ return false;
+ }
+
+ this.editor.commands.pasteMarkdown(content);
+
+ return true;
+ },
+ },
+ }),
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js
index 004bb8b815c..d7456ab4094 100644
--- a/app/assets/javascripts/content_editor/extensions/table.js
+++ b/app/assets/javascripts/content_editor/extensions/table.js
@@ -1,5 +1,6 @@
import { Table } from '@tiptap/extension-table';
import { debounce } from 'lodash';
+import { VARIANT_WARNING } from '~/flash';
import { __ } from '~/locale';
import { getMarkdownSource } from '../services/markdown_sourcemap';
import { shouldRenderHTMLTable } from '../services/serialization_helpers';
@@ -14,7 +15,7 @@ const onUpdate = debounce((editor) => {
message: __(
'The content editor may change the markdown formatting style of the document, which may not match your original markdown style.',
),
- variant: 'warning',
+ variant: VARIANT_WARNING,
});
alertShown = true;
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index a387322bff7..c5638da2daf 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,17 +1,23 @@
-import eventHubFactory from '~/helpers/event_hub_factory';
+import { TextSelection } from 'prosemirror-state';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
+
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
- constructor({ tiptapEditor, serializer }) {
+ constructor({ tiptapEditor, serializer, deserializer, eventHub }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
- this._eventHub = eventHubFactory();
+ this._deserializer = deserializer;
+ this._eventHub = eventHub;
}
get tiptapEditor() {
return this._tiptapEditor;
}
+ get eventHub() {
+ return this._eventHub;
+ }
+
get empty() {
const doc = this.tiptapEditor?.state.doc;
@@ -23,39 +29,31 @@ export class ContentEditor {
this.tiptapEditor.destroy();
}
- once(type, handler) {
- this._eventHub.$once(type, handler);
- }
-
- on(type, handler) {
- this._eventHub.$on(type, handler);
- }
-
- emit(type, params = {}) {
- this._eventHub.$emit(type, params);
- }
-
- off(type, handler) {
- this._eventHub.$off(type, handler);
- }
-
disposeAllEvents() {
this._eventHub.dispose();
}
async setSerializedContent(serializedContent) {
- const { _tiptapEditor: editor, _serializer: serializer } = this;
+ const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this;
+ const { doc, tr } = editor.state;
+ const selection = TextSelection.create(doc, 0, doc.content.size);
try {
- this._eventHub.$emit(LOADING_CONTENT_EVENT);
- const document = await serializer.deserialize({
+ eventHub.$emit(LOADING_CONTENT_EVENT);
+ const { document } = await deserializer.deserialize({
schema: editor.schema,
content: serializedContent,
});
- editor.commands.setContent(document);
- this._eventHub.$emit(LOADING_SUCCESS_EVENT);
+
+ if (document) {
+ tr.setSelection(selection)
+ .replaceSelectionWith(document, false)
+ .setMeta('preventUpdate', true);
+ editor.view.dispatch(tr);
+ }
+ eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) {
- this._eventHub.$emit(LOADING_ERROR_EVENT, e);
+ eventHub.$emit(LOADING_ERROR_EVENT, e);
throw e;
}
}
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 f451357e211..d9d39a387d0 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -1,5 +1,6 @@
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
+import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
import Audio from '../extensions/audio';
@@ -38,6 +39,7 @@ import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
+import PasteMarkdown from '../extensions/paste_markdown';
import Reference from '../extensions/reference';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
@@ -54,6 +56,7 @@ import Video from '../extensions/video';
import WordBreak from '../extensions/word_break';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
+import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
@@ -78,8 +81,10 @@ export const createContentEditor = ({
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
}
+ const eventHub = eventHubFactory();
+
const builtInContentEditorExtensions = [
- Attachment.configure({ uploadsPath, renderMarkdown }),
+ Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio,
Blockquote,
Bold,
@@ -116,6 +121,7 @@ export const createContentEditor = ({
MathInline,
OrderedList,
Paragraph,
+ PasteMarkdown.configure({ renderMarkdown, eventHub }),
Reference,
Strike,
Subscript,
@@ -135,7 +141,8 @@ export const createContentEditor = ({
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
- const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
+ const serializer = createMarkdownSerializer({ serializerConfig });
+ const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- return new ContentEditor({ tiptapEditor, serializer });
+ return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer });
};
diff --git a/app/assets/javascripts/content_editor/services/markdown_deserializer.js b/app/assets/javascripts/content_editor/services/markdown_deserializer.js
new file mode 100644
index 00000000000..cd4863d8eac
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/markdown_deserializer.js
@@ -0,0 +1,33 @@
+import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+
+export default ({ render }) => {
+ /**
+ * Converts a Markdown string into a ProseMirror JSONDocument based
+ * on a ProseMirror schema.
+ *
+ * @param {Object} options — The schema and content for deserialization
+ * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
+ * the types of content supported in the document
+ * @param {String} params.content An arbitrary markdown string
+ *
+ * @returns An object with the following properties:
+ * - document: A ProseMirror document object generated from the deserialized Markdown
+ * - dom: The Markdown Deserializer renders Markdown as HTML to generate the ProseMirror
+ * document. The dom property contains the HTML generated from the Markdown Source.
+ */
+ return {
+ deserialize: async ({ schema, content }) => {
+ const html = await render(content);
+
+ if (!html) return {};
+
+ const parser = new DOMParser();
+ const { body } = parser.parseFromString(html, 'text/html');
+
+ // append original source as a comment that nodes can access
+ body.append(document.createComment(content));
+
+ return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body), dom: body };
+ },
+ };
+};
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 925b411e51c..eaaf69c3068 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -1,4 +1,3 @@
-import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
import {
MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer,
@@ -237,31 +236,7 @@ const defaultSerializerConfig = {
* that parses the Markdown and converts it into HTML.
* @returns a markdown serializer
*/
-export default ({ render = () => null, serializerConfig = {} } = {}) => ({
- /**
- * Converts a Markdown string into a ProseMirror JSONDocument based
- * on a ProseMirror schema.
- * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
- * the types of content supported in the document
- * @param {String} params.content An arbitrary markdown string
- * @returns A ProseMirror JSONDocument
- */
- deserialize: async ({ schema, content }) => {
- const html = await render(content);
-
- if (!html) return null;
-
- const parser = new DOMParser();
- const { body } = 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();
- },
-
+export default ({ serializerConfig = {} } = {}) => ({
/**
* Converts a ProseMirror JSONDocument based
* on a ProseMirror schema into Markdown
diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
index a1199589c9b..4285e04bbab 100644
--- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
+++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
@@ -1,7 +1,9 @@
-const getFullSource = (element) => {
+import { isString } from 'lodash';
+
+export const getFullSource = (element) => {
const commentNode = element.ownerDocument.body.lastChild;
- if (commentNode.nodeName === '#comment') {
+ if (commentNode?.nodeName === '#comment' && isString(commentNode.textContent)) {
return commentNode.textContent.split('\n');
}
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 4d5a54c0347..5fdd294aa96 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -259,11 +259,16 @@ export function renderContent(state, node, forceRenderInline) {
}
}
-export function renderHTMLNode(tagName, forceRenderInline = false) {
+export function renderHTMLNode(tagName, forceRenderContentInline = false) {
return (state, node) => {
renderTagOpen(state, tagName, node.attrs);
- renderContent(state, node, forceRenderInline);
+ renderContent(state, node, forceRenderContentInline);
renderTagClose(state, tagName, false);
+
+ if (forceRenderContentInline) {
+ state.closeBlock(node);
+ state.flushClose();
+ }
};
}
diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
index f5bf2742748..1abecb8f414 100644
--- a/app/assets/javascripts/content_editor/services/upload_helpers.js
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -1,3 +1,4 @@
+import { VARIANT_DANGER } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { extractFilename, readFileAsDataURL } from './utils';
@@ -49,7 +50,7 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
return extractAttachmentLinkUrl(rendered);
};
-const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
+const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
const encodedSrc = await readFileAsDataURL(file);
const { view } = editor;
@@ -72,14 +73,14 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
);
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
- editor.emit('alert', {
+ eventHub.$emit('alert', {
message: __('An error occurred while uploading the image. Please try again.'),
- variant: 'danger',
+ variant: VARIANT_DANGER,
});
}
};
-const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => {
+const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
await Promise.resolve();
const { view } = editor;
@@ -103,23 +104,23 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) =
);
} catch (e) {
editor.commands.deleteRange({ from, to: from + 1 });
- editor.emit('alert', {
+ eventHub.$emit('alert', {
message: __('An error occurred while uploading the file. Please try again.'),
- variant: 'danger',
+ variant: VARIANT_DANGER,
});
}
};
-export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
+export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
if (!file) return false;
if (acceptedMimes.image.includes(file?.type)) {
- uploadImage({ editor, file, uploadsPath, renderMarkdown });
+ uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
}
- uploadAttachment({ editor, file, uploadsPath, renderMarkdown });
+ uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
};
diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js
index 45b569066f8..79f5c701fb8 100644
--- a/app/assets/javascripts/contributors/stores/getters.js
+++ b/app/assets/javascripts/contributors/stores/getters.js
@@ -7,10 +7,11 @@ export const parsedData = (state) => {
state.chartData.forEach(({ date, author_name, author_email }) => {
total[date] = total[date] ? total[date] + 1 : 1;
- const authorData = byAuthorEmail[author_email];
+ const normalizedEmail = author_email.toLowerCase();
+ const authorData = byAuthorEmail[normalizedEmail];
if (!authorData) {
- byAuthorEmail[author_email] = {
+ byAuthorEmail[normalizedEmail] = {
name: author_name,
commits: 1,
dates: {
diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
deleted file mode 100644
index 4c44aac4e2a..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- count: {
- type: Number,
- required: true,
- },
- },
-};
-</script>
-<template>
- <span v-if="count === 50" class="events-info float-right">
- <gl-icon
- v-gl-tooltip="{
- title: n__(
- 'Limited to showing %d event at most',
- 'Limited to showing %d events at most',
- 50,
- ),
- }"
- name="warning"
- />
- {{ n__('Showing %d event', 'Showing %d events', 50) }}
- </span>
-</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
index ea5a1291a17..6a45969fd1a 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -18,7 +18,7 @@ import {
PAGINATION_SORT_DIRECTION_ASC,
PAGINATION_SORT_DIRECTION_DESC,
} from '../constants';
-import TotalTime from './total_time_component.vue';
+import TotalTime from './total_time.vue';
const DEFAULT_WORKFLOW_TITLE_PROPERTIES = {
thClass: 'gl-w-half',
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue b/app/assets/javascripts/cycle_analytics/components/total_time.vue
index a5a90a56974..a5a90a56974 100644
--- a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/total_time.vue
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 64461797c46..66bccf19496 100644
--- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
+++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
@@ -1,16 +1,28 @@
<script>
+import { GlIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
import DateRange from '~/analytics/shared/components/daterange.vue';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import { DATE_RANGE_LIMIT, PROJECTS_PER_PAGE } from '~/analytics/shared/constants';
import FilterBar from './filter_bar.vue';
+export const AGGREGATION_TOGGLE_LABEL = s__('CycleAnalytics|Filter by stop date');
+export const AGGREGATION_DESCRIPTION = s__(
+ 'CycleAnalytics|When enabled, the results show items with a stop event within the date range. When disabled, the results show items with a start event within the date range.',
+);
+
export default {
name: 'ValueStreamFilters',
components: {
+ GlIcon,
+ GlToggle,
DateRange,
ProjectsDropdownFilter,
FilterBar,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
selectedProjects: {
type: Array,
@@ -45,6 +57,21 @@ export default {
required: false,
default: null,
},
+ canToggleAggregation: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isAggregationEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isUpdatingAggregationData: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
projectsQueryParams() {
@@ -54,8 +81,19 @@ export default {
};
},
},
+ methods: {
+ onUpdateAggregation(ev) {
+ if (!this.isUpdatingAggregationData) {
+ this.$emit('toggleAggregation', ev);
+ }
+ },
+ },
multiProjectSelect: true,
maxDateRange: DATE_RANGE_LIMIT,
+ i18n: {
+ AGGREGATION_TOGGLE_LABEL,
+ AGGREGATION_DESCRIPTION,
+ },
};
</script>
<template>
@@ -84,7 +122,28 @@ export default {
@selected="$emit('selectProject', $event)"
/>
</div>
- <div>
+ <div class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row">
+ <div
+ v-if="canToggleAggregation"
+ class="gl-display-flex gl-text-align-center gl-my-2 gl-lg-mt-0 gl-lg-mb-0 gl-mr-5"
+ >
+ <gl-toggle
+ class="gl-flex-direction-row"
+ :value="isAggregationEnabled"
+ :label="$options.i18n.AGGREGATION_TOGGLE_LABEL"
+ :disabled="isUpdatingAggregationData"
+ label-position="left"
+ @change="onUpdateAggregation"
+ >
+ <template #label>
+ {{ $options.i18n.AGGREGATION_TOGGLE_LABEL }}&nbsp;<gl-icon
+ v-gl-tooltip.hover
+ :title="$options.i18n.AGGREGATION_DESCRIPTION"
+ name="information-o"
+ />
+ </template>
+ </gl-toggle>
+ </div>
<date-range
v-if="hasDateRangeFilter"
:start-date="startDate"
diff --git a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
index fdf8b7796bf..7879357a042 100644
--- a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
+++ b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
@@ -17,9 +17,6 @@ export default {
revokePath: {
default: '',
},
- buttonClass: {
- default: '',
- },
},
computed: {
modalId() {
@@ -38,10 +35,9 @@ export default {
<div>
<gl-button
v-gl-modal="modalId"
- :class="buttonClass"
category="primary"
variant="danger"
- class="float-right"
+ class="gl-float-right"
data-testid="revoke-button"
>{{ s__('DeployTokens|Revoke') }}</gl-button
>
diff --git a/app/assets/javascripts/deploy_tokens/init_revoke_button.js b/app/assets/javascripts/deploy_tokens/init_revoke_button.js
index 20187150a60..bc3f3c9ddf4 100644
--- a/app/assets/javascripts/deploy_tokens/init_revoke_button.js
+++ b/app/assets/javascripts/deploy_tokens/init_revoke_button.js
@@ -9,14 +9,13 @@ export default () => {
}
return containers.forEach((el) => {
- const { token, revokePath, buttonClass } = el.dataset;
+ const { token, revokePath } = el.dataset;
return new Vue({
el,
provide: {
token: JSON.parse(token),
revokePath,
- buttonClass,
},
render(h) {
return h(RevokeButton);
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 82bbbe891e2..0707ae02872 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -1,6 +1,6 @@
/* eslint-disable no-restricted-properties, babel/camelcase,
no-unused-expressions, default-case,
-consistent-return, no-alert, no-param-reassign,
+consistent-return, no-param-reassign,
no-shadow, no-useless-escape,
class-methods-use-this */
@@ -17,9 +17,11 @@ import { escape, uniqueId } from 'lodash';
import Vue from 'vue';
import '~/lib/utils/jquery_at_who';
import AjaxCache from '~/lib/utils/ajax_cache';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import syntaxHighlight from '~/syntax_highlight';
import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue';
import * as constants from '~/notes/constants';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import Autosave from './autosave';
import loadAwardsHandler from './awards_handler';
import createFlash from './flash';
@@ -243,7 +245,7 @@ export default class Notes {
});
}
- keydownNoteText(e) {
+ async keydownNoteText(e) {
let discussionNoteForm;
let editNote;
let myLastNote;
@@ -276,9 +278,11 @@ export default class Notes {
discussionNoteForm = $textarea.closest('.js-discussion-note-form');
if (discussionNoteForm.length) {
if ($textarea.val() !== '') {
- if (!window.confirm(__('Your comment will be discarded.'))) {
- return;
- }
+ const confirmed = await confirmAction(__('Your comment will be discarded.'), {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: __('Discard'),
+ });
+ if (!confirmed) return;
}
this.removeDiscussionNoteForm(discussionNoteForm);
return;
@@ -288,9 +292,14 @@ export default class Notes {
originalText = $textarea.closest('form').data('originalNote');
newText = $textarea.val();
if (originalText !== newText) {
- if (!window.confirm(__('Are you sure you want to discard this comment?'))) {
- return;
- }
+ const confirmed = await confirmAction(
+ __('Are you sure you want to discard this comment?'),
+ {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: __('Discard'),
+ },
+ );
+ if (!confirmed) return;
}
return this.removeNoteEditForm(editNote);
}
@@ -1753,9 +1762,11 @@ export default class Notes {
// Show updated comment content temporarily
$noteBodyText.html(formContent);
$editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half');
- $editingNote
- .find('.note-headline-meta a')
- .html('<span class="spinner align-text-bottom"></span>');
+
+ const $timeAgo = $editingNote.find('.note-headline-meta a');
+
+ $timeAgo.empty();
+ $timeAgo.append(loadingIconForLegacyJS({ inline: true, size: 'sm' }));
// Make request to update comment on server
axios
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 14d6e2db09d..a12829f8420 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { merge } from 'lodash';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -41,6 +42,8 @@ export default class Diff {
}
this.openAnchoredDiff();
+
+ this.prepareRenderedDiff();
}
handleClickUnfold(e) {
@@ -150,4 +153,43 @@ export default class Diff {
.addClass('hll');
}
}
+
+ prepareRenderedDiff() {
+ const $elements = $('[data-diff-toggle-entity]');
+
+ if ($elements.length === 0) return;
+
+ const diff = this;
+
+ const elements = $elements.toArray().map(this.formatElementToObject).reduce(merge);
+
+ Object.values(elements).forEach((e) => {
+ e.toShowBtn.onclick = () => diff.showOneHideAnother('rendered', e); // eslint-disable-line no-param-reassign
+ e.toHideBtn.onclick = () => diff.showOneHideAnother('raw', e); // eslint-disable-line no-param-reassign
+
+ diff.showOneHideAnother('rendered', e);
+ });
+ }
+
+ formatElementToObject = (element) => {
+ const key = element.attributes['data-file-hash'].value;
+ const name = element.attributes['data-diff-toggle-entity'].value;
+
+ return { [key]: { [name]: element } };
+ };
+
+ showOneHideAnother = (mode, elements) => {
+ let { toShowBtn, toHideBtn, toShow, toHide } = elements;
+
+ if (mode === 'raw') {
+ [toShowBtn, toHideBtn] = [toHideBtn, toShowBtn];
+ [toShow, toHide] = [toHide, toShow];
+ }
+
+ toShowBtn.classList.add('selected');
+ toHideBtn.classList.remove('selected');
+
+ toHide.classList.add('hidden');
+ toShow.classList.remove('hidden');
+ };
}
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index df7cf83b3f0..ba10f6deb29 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -72,8 +72,6 @@ export default {
return this.author.id ? this.author.id : '';
},
authorUrl() {
- // name: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
- // eslint-disable-next-line @gitlab/require-i18n-strings
return this.author.web_url || `mailto:${this.commit.author_email}`;
},
authorAvatar() {
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 858d9e221ae..7ed5713ebfa 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -163,8 +163,8 @@ export default {
v-if="diffFile.discussions.length"
class="diff-file-discussions"
:discussions="diffFile.discussions"
- :should-collapse-discussions="true"
- :render-avatar-badge="true"
+ should-collapse-discussions
+ render-avatar-badge
/>
<diff-file-drafts :file-hash="diffFileHash" class="diff-file-discussions" />
<note-form
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 3cf1f69b08c..495c87a695c 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -431,7 +431,7 @@ export default {
class="js-ide-edit-blob"
data-qa-selector="edit_in_ide_button"
>
- {{ __('Edit in Web IDE') }}
+ {{ __('Open in Web IDE') }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 333bf1b356c..f46b0a538f1 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -1,4 +1,5 @@
<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapGetters, mapState, mapActions } from 'vuex';
import { IdState } from 'vendor/vue-virtual-scroller';
import DraftNote from '~/batch_comments/components/draft_note.vue';
@@ -19,6 +20,9 @@ export default {
DiffCommentCell,
DraftNote,
},
+ directives: {
+ SafeHtml,
+ },
mixins: [
draftCommentsMixin,
glFeatureFlagsMixin(),
@@ -173,15 +177,17 @@ export default {
<div class="diff-grid-left diff-grid-3-col left-side">
<div class="diff-td diff-line-num"></div>
<div v-if="inline" class="diff-td diff-line-num"></div>
- <div class="diff-td line_content left-side gl-white-space-normal!">
- {{ line.left.rich_text }}
- </div>
+ <div
+ v-safe-html="line.left.rich_text"
+ class="diff-td line_content left-side gl-white-space-normal!"
+ ></div>
</div>
<div v-if="!inline" class="diff-grid-right diff-grid-3-col right-side">
<div class="diff-td diff-line-num"></div>
- <div class="diff-td line_content right-side gl-white-space-normal!">
- {{ line.left.rich_text }}
- </div>
+ <div
+ v-safe-html="line.left.rich_text"
+ class="diff-td line_content right-side gl-white-space-normal!"
+ ></div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
index baf7471582a..b9962682848 100644
--- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
@@ -1,6 +1,19 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export const i18n = {
+ title: __('Too many changes to show.'),
+ plainDiff: __('Plain diff'),
+ emailPatch: __('Email patch'),
+};
+
export default {
+ i18n,
+ components: {
+ GlAlert,
+ GlSprintf,
+ },
props: {
total: {
type: String,
@@ -23,17 +36,28 @@ export default {
</script>
<template>
- <div class="alert alert-warning">
- <h4>
- {{ __('Too many changes to show.') }}
- <div class="float-right">
- <a :href="plainDiffPath" class="btn btn-sm"> {{ __('Plain diff') }} </a>
- <a :href="emailPatchPath" class="btn btn-sm"> {{ __('Email patch') }} </a>
- </div>
- </h4>
- <p>
- To preserve performance only <strong> {{ visible }} of {{ total }} </strong> files are
- displayed.
- </p>
- </div>
+ <gl-alert
+ variant="warning"
+ :title="$options.i18n.title"
+ :primary-button-text="$options.i18n.plainDiff"
+ :primary-button-link="plainDiffPath"
+ :secondary-button-text="$options.i18n.emailPatch"
+ :secondary-button-link="emailPatchPath"
+ :dismissible="false"
+ >
+ <gl-sprintf
+ :message="
+ sprintf(
+ __(
+ 'To preserve performance only %{strongStart}%{visible} of %{total}%{strongEnd} files are displayed.',
+ ),
+ { visible, total },
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
</template>
diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
index 65ffd42fa27..734407dec45 100644
--- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
+++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
@@ -15,24 +15,19 @@ export const diffCompareDropdownTargetVersions = (state, getters) => {
// startVersion only exists if the user has selected a version other
// than "base" so if startVersion is null then base must be selected
- const defaultMergeRefForDiffs = window.gon?.features?.defaultMergeRefForDiffs || false;
const diffHeadParam = getParameterByName('diff_head');
- const diffHead = parseBoolean(diffHeadParam) || (!diffHeadParam && defaultMergeRefForDiffs);
- const isBaseSelected = !state.startVersion && !diffHead;
+ const diffHead = parseBoolean(diffHeadParam) || !diffHeadParam;
+ const isBaseSelected = !state.startVersion;
const isHeadSelected = !state.startVersion && diffHead;
let baseVersion = null;
- if (
- !defaultMergeRefForDiffs ||
- (defaultMergeRefForDiffs && !state.mergeRequestDiff.head_version_path)
- ) {
+ if (!state.mergeRequestDiff.head_version_path) {
baseVersion = {
versionName: state.targetBranchName,
version_index: DIFF_COMPARE_BASE_VERSION_INDEX,
href: state.mergeRequestDiff.base_version_path,
isBase: true,
- selected:
- isBaseSelected || (defaultMergeRefForDiffs && !state.mergeRequestDiff.head_version_path),
+ selected: isBaseSelected,
};
}
diff --git a/app/assets/javascripts/dirty_submit/dirty_submit_form.js b/app/assets/javascripts/dirty_submit/dirty_submit_form.js
index db13daf0799..83dd4b0a124 100644
--- a/app/assets/javascripts/dirty_submit/dirty_submit_form.js
+++ b/app/assets/javascripts/dirty_submit/dirty_submit_form.js
@@ -1,11 +1,13 @@
import $ from 'jquery';
import { memoize, throttle } from 'lodash';
+import createEventHub from '~/helpers/event_hub_factory';
class DirtySubmitForm {
constructor(form) {
this.form = form;
this.dirtyInputs = [];
this.isDisabled = true;
+ this.events = createEventHub();
this.init();
}
@@ -36,11 +38,21 @@ class DirtySubmitForm {
this.form.addEventListener('submit', (event) => this.formSubmit(event));
}
+ addInputsListener(callback) {
+ this.events.$on('input', callback);
+ }
+
+ removeInputsListener(callback) {
+ this.events.$off('input', callback);
+ }
+
updateDirtyInput(event) {
const { target } = event;
if (!target.dataset.isDirtySubmitInput) return;
+ this.events.$emit('input', event);
+
this.updateDirtyInputs(target);
this.toggleSubmission();
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
index 0290bb84b5f..b41eae88c54 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
@@ -14,7 +14,7 @@ export class CiSchemaExtension {
// to fetch schema files, hence the `gon.gitlab_url`
// reference. This prevents error:
// "Failed to execute 'fetch' on 'WorkerGlobalScope'"
- const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath;
+ const absoluteSchemaUrl = new URL(ciSchemaPath, gon.gitlab_url).href;
const modelFileName = instance.getModel().uri.path.split('/').pop();
registerSchema({
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 4d9fe6ff851..1c56327c03c 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -423,37 +423,34 @@
"description": "Defines secrets to be injected as environment variables",
"additionalProperties": {
"type": "object",
- "additionalProperties": {
- "type": "object",
- "description": "Environment variable name",
- "properties": {
- "vault": {
- "oneOf": [
- {
- "type": "string",
- "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)"
- },
- {
- "type": "object",
- "properties": {
- "engine": {
- "type": "object",
- "properties": {
- "name": { "type": "string" },
- "path": { "type": "string" }
- },
- "required": ["name", "path"]
+ "description": "Environment variable name",
+ "properties": {
+ "vault": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "engine": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "path": { "type": "string" }
},
- "path": { "type": "string" },
- "field": { "type": "string" }
+ "required": ["name", "path"]
},
- "required": ["engine", "path", "field"]
- }
- ]
- }
- },
- "required": ["vault"]
- }
+ "path": { "type": "string" },
+ "field": { "type": "string" }
+ },
+ "required": ["engine", "path", "field"]
+ }
+ ]
+ }
+ },
+ "required": ["vault"]
}
},
"before_script": {
@@ -1250,7 +1247,7 @@
"oneOf": [
{
"type": "object",
- "description": "Trigger a multi-project pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#simple-trigger-syntax-for-multi-project-pipelines",
+ "description": "Trigger a multi-project pipeline. Read more: https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#specify-a-downstream-pipeline-branch",
"additionalProperties": false,
"properties": {
"project": {
@@ -1266,6 +1263,23 @@
"description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend",
"type": "string",
"enum": ["depend"]
+ },
+ "forward": {
+ "description": "Specify what to forward to the downstream pipeline.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "yaml_variables": {
+ "type": "boolean",
+ "description": "Variables defined in the trigger job are passed to downstream pipelines.",
+ "default": true
+ },
+ "pipeline_variables": {
+ "type": "boolean",
+ "description": "Variables added for manual pipeline runs are passed to downstream pipelines.",
+ "default": false
+ }
+ }
}
},
"required": ["project"],
@@ -1275,7 +1289,7 @@
},
{
"type": "object",
- "description": "Trigger a child pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#trigger-syntax-for-child-pipeline",
+ "description": "Trigger a child pipeline. Read more: https://docs.gitlab.com/ee/ci/pipelines/parent_child_pipelines.html",
"additionalProperties": false,
"properties": {
"include": {
@@ -1365,11 +1379,28 @@
"description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend",
"type": "string",
"enum": ["depend"]
+ },
+ "forward": {
+ "description": "Specify what to forward to the downstream pipeline.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "yaml_variables": {
+ "type": "boolean",
+ "description": "Variables defined in the trigger job are passed to downstream pipelines.",
+ "default": true
+ },
+ "pipeline_variables": {
+ "type": "boolean",
+ "description": "Variables added for manual pipeline runs are passed to downstream pipelines.",
+ "default": false
+ }
+ }
}
}
},
{
- "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.",
+ "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`. Read more: https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#define-multi-project-pipelines-in-your-gitlab-ciyml-file",
"type": "string",
"pattern": "\\S/\\S"
}
diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue
index 686b5ffff9e..840297b870a 100644
--- a/app/assets/javascripts/emoji/components/picker.vue
+++ b/app/assets/javascripts/emoji/components/picker.vue
@@ -108,6 +108,7 @@ export default {
class="gl-mx-5! gl-mb-2!"
autofocus
debounce="500"
+ :aria-label="__('Search for an emoji')"
@input="onSearchInput"
/>
<div
diff --git a/app/assets/javascripts/environments/components/commit.vue b/app/assets/javascripts/environments/components/commit.vue
index 54b94480685..8577bf629a3 100644
--- a/app/assets/javascripts/environments/components/commit.vue
+++ b/app/assets/javascripts/environments/components/commit.vue
@@ -22,7 +22,6 @@ export default {
return this.commit?.message;
},
commitAuthorPath() {
- // eslint-disable-next-line @gitlab/require-i18n-strings
return this.commit?.author?.path || `mailto:${escape(this.commit?.authorEmail)}`;
},
commitAuthorAvatar() {
diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue
index d3d4c7d23d8..3173c2bd644 100644
--- a/app/assets/javascripts/environments/components/delete_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue
@@ -62,7 +62,8 @@ export default {
mutation: deleteEnvironmentMutation,
variables: { environment: this.environment },
})
- .then(([message]) => {
+ .then(({ data }) => {
+ const [message] = data?.deleteEvironment?.errors ?? [];
if (message) {
createFlash({ message });
}
diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue
index f98edb6bb7d..19284b26d51 100644
--- a/app/assets/javascripts/environments/components/deployment.vue
+++ b/app/assets/javascripts/environments/components/deployment.vue
@@ -102,6 +102,9 @@ export default {
refPath() {
return this.ref?.refPath;
},
+ needsApproval() {
+ return this.deployment.pendingApprovalCount > 0;
+ },
},
methods: {
toggleCollapse() {
@@ -116,6 +119,7 @@ export default {
showDetails: __('Show details'),
hideDetails: __('Hide details'),
triggerer: s__('Deployment|Triggerer'),
+ needsApproval: s__('Deployment|Needs Approval'),
job: __('Job'),
api: __('API'),
branch: __('Branch'),
@@ -153,6 +157,9 @@ export default {
<div :class="$options.headerDetailsClasses">
<div :class="$options.deploymentStatusClasses">
<deployment-status-badge v-if="status" :status="status" />
+ <gl-badge v-if="needsApproval" variant="warning">
+ {{ $options.i18n.needsApproval }}
+ </gl-badge>
<gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge>
</div>
<div class="gl-display-flex gl-align-items-center gl-gap-x-5">
@@ -199,6 +206,7 @@ export default {
</gl-button>
</div>
<commit v-if="commit" :commit="commit" class="gl-mt-3" />
+ <div class="gl-mt-3"><slot name="approval"></slot></div>
<gl-collapse :visible="visible">
<div
class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0"
diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
index b757c55bfdb..4d43ee156fb 100644
--- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue
+++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink, GlModal, GlSprintf } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
@@ -44,6 +45,11 @@ export default {
copyToClipboardText: s__('EnableReviewApp|Copy snippet text'),
title: s__('ReviewApp|Enable Review App'),
},
+ data() {
+ const modalInfoCopyId = uniqueId('enable-review-app-copy-string-');
+
+ return { modalInfoCopyId };
+ },
computed: {
modalInfoCopyStr() {
return `deploy_review:
@@ -99,14 +105,14 @@ export default {
</gl-sprintf>
</p>
<div class="gl-display-flex align-items-start">
- <pre class="gl-w-full" data-testid="enable-review-app-copy-string">
+ <pre :id="modalInfoCopyId" class="gl-w-full" data-testid="enable-review-app-copy-string">
{{ modalInfoCopyStr }} </pre
>
<modal-copy-button
:title="$options.modalInfo.copyToClipboardText"
- :text="$options.modalInfo.copyString"
:modal-id="modalId"
css-classes="border-0"
+ :target="`#${modalInfoCopyId}`"
/>
</div>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 98c95507168..c7e024aadec 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,5 +1,6 @@
<script>
import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { formatTime } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
@@ -37,7 +38,7 @@ export default {
},
},
methods: {
- onClickAction(action) {
+ async onClickAction(action) {
if (action.scheduledAt) {
const confirmationMessage = sprintf(
s__(
@@ -45,9 +46,10 @@ export default {
),
{ jobName: action.name },
);
- // https://gitlab.com/gitlab-org/gitlab-foss/issues/52156
- // eslint-disable-next-line no-alert
- if (!window.confirm(confirmationMessage)) {
+
+ const confirmed = await confirmAction(confirmationMessage);
+
+ if (!confirmed) {
return;
}
}
diff --git a/app/assets/javascripts/environments/components/new_environment_folder.vue b/app/assets/javascripts/environments/components/environment_folder.vue
index 0d3867a4d74..d5c6d26cfd0 100644
--- a/app/assets/javascripts/environments/components/new_environment_folder.vue
+++ b/app/assets/javascripts/environments/components/environment_folder.vue
@@ -1,7 +1,9 @@
<script>
import { GlButton, GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql';
import folderQuery from '../graphql/queries/folder.query.graphql';
+import { ENVIRONMENT_COUNT_BY_SCOPE } from '../constants';
import EnvironmentItem from './new_environment_item.vue';
export default {
@@ -18,16 +20,26 @@ export default {
type: Object,
required: true,
},
+ scope: {
+ type: String,
+ required: true,
+ },
},
data() {
- return { visible: false };
+ return { visible: false, interval: undefined };
},
apollo: {
folder: {
query: folderQuery,
variables() {
- return { environment: this.nestedEnvironment.latest };
+ return { environment: this.nestedEnvironment.latest, scope: this.scope };
},
+ pollInterval() {
+ return this.interval;
+ },
+ },
+ interval: {
+ query: pollIntervalQuery,
},
},
i18n: {
@@ -45,7 +57,8 @@ export default {
return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand;
},
count() {
- return this.folder?.availableCount ?? 0;
+ const count = ENVIRONMENT_COUNT_BY_SCOPE[this.scope];
+ return this.folder?.[count] ?? 0;
},
folderClass() {
return { 'gl-font-weight-bold': this.visible };
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index acc16ecd874..c7008c03099 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,188 +1,272 @@
<script>
-import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui';
-import createFlash from '~/flash';
-import { s__ } from '~/locale';
-import eventHub from '../event_hub';
-import environmentsMixin from '../mixins/environments_mixin';
-import EnvironmentsPaginationApiMixin from '../mixins/environments_pagination_api_mixin';
-import ConfirmRollbackModal from './confirm_rollback_modal.vue';
-import DeleteEnvironmentModal from './delete_environment_modal.vue';
-import emptyState from './empty_state.vue';
+import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
+import { s__, __, sprintf } from '~/locale';
+import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
+import environmentAppQuery from '../graphql/queries/environment_app.query.graphql';
+import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql';
+import pageInfoQuery from '../graphql/queries/page_info.query.graphql';
+import environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql';
+import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql';
+import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql';
+import environmentToChangeCanaryQuery from '../graphql/queries/environment_to_change_canary.query.graphql';
+import { ENVIRONMENTS_SCOPE } from '../constants';
+import EnvironmentFolder from './environment_folder.vue';
import EnableReviewAppModal from './enable_review_app_modal.vue';
import StopEnvironmentModal from './stop_environment_modal.vue';
+import EnvironmentItem from './new_environment_item.vue';
+import ConfirmRollbackModal from './confirm_rollback_modal.vue';
+import DeleteEnvironmentModal from './delete_environment_modal.vue';
+import CanaryUpdateModal from './canary_update_modal.vue';
+import EmptyState from './empty_state.vue';
export default {
- i18n: {
- newEnvironmentButtonLabel: s__('Environments|New environment'),
- reviewAppButtonLabel: s__('Environments|Enable review app'),
- },
- modal: {
- id: 'enable-review-app-info',
- },
components: {
+ DeleteEnvironmentModal,
+ CanaryUpdateModal,
ConfirmRollbackModal,
- emptyState,
+ EmptyState,
+ EnvironmentFolder,
EnableReviewAppModal,
+ EnvironmentItem,
+ StopEnvironmentModal,
GlBadge,
- GlButton,
+ GlPagination,
GlTab,
GlTabs,
- StopEnvironmentModal,
- DeleteEnvironmentModal,
},
- directives: {
- 'gl-modal': GlModalDirective,
- },
- mixins: [EnvironmentsPaginationApiMixin, environmentsMixin],
- props: {
- endpoint: {
- type: String,
- required: true,
+ apollo: {
+ environmentApp: {
+ query: environmentAppQuery,
+ variables() {
+ return {
+ scope: this.scope,
+ page: this.page ?? 1,
+ };
+ },
+ pollInterval() {
+ return this.interval;
+ },
+ },
+ interval: {
+ query: pollIntervalQuery,
+ },
+ pageInfo: {
+ query: pageInfoQuery,
+ },
+ environmentToDelete: {
+ query: environmentToDeleteQuery,
},
- canCreateEnvironment: {
- type: Boolean,
- required: true,
+ environmentToRollback: {
+ query: environmentToRollbackQuery,
},
- newEnvironmentPath: {
- type: String,
- required: true,
+ environmentToStop: {
+ query: environmentToStopQuery,
},
- helpPagePath: {
- type: String,
- required: true,
+ environmentToChangeCanary: {
+ query: environmentToChangeCanaryQuery,
+ },
+ weight: {
+ query: environmentToChangeCanaryQuery,
},
},
-
- created() {
- eventHub.$on('toggleFolder', this.toggleFolder);
- eventHub.$on('toggleDeployBoard', this.toggleDeployBoard);
+ inject: ['newEnvironmentPath', 'canCreateEnvironment', 'helpPagePath'],
+ i18n: {
+ newEnvironmentButtonLabel: s__('Environments|New environment'),
+ reviewAppButtonLabel: s__('Environments|Enable review app'),
+ available: __('Available'),
+ stopped: __('Stopped'),
+ prevPage: __('Go to previous page'),
+ nextPage: __('Go to next page'),
+ next: __('Next'),
+ prev: __('Prev'),
+ goto: (page) => sprintf(__('Go to page %{page}'), { page }),
},
-
- beforeDestroy() {
- // eslint-disable-next-line @gitlab/no-global-event-off
- eventHub.$off('toggleFolder');
- // eslint-disable-next-line @gitlab/no-global-event-off
- eventHub.$off('toggleDeployBoard');
+ modalId: 'enable-review-app-info',
+ data() {
+ const { page = '1', scope } = queryToObject(window.location.search);
+ return {
+ interval: undefined,
+ isReviewAppModalVisible: false,
+ page: parseInt(page, 10),
+ pageInfo: {},
+ scope: Object.values(ENVIRONMENTS_SCOPE).includes(scope)
+ ? scope
+ : ENVIRONMENTS_SCOPE.AVAILABLE,
+ environmentToDelete: {},
+ environmentToRollback: {},
+ environmentToStop: {},
+ environmentToChangeCanary: {},
+ weight: 0,
+ };
},
-
- methods: {
- toggleDeployBoard(model) {
- this.store.toggleDeployBoard(model.id);
+ computed: {
+ canSetupReviewApp() {
+ return this.environmentApp?.reviewApp?.canSetupReviewApp;
},
- toggleFolder(folder) {
- this.store.toggleFolder(folder);
-
- if (!folder.isOpen) {
- this.fetchChildEnvironments(folder, true);
- }
+ folders() {
+ return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? [];
},
-
- fetchChildEnvironments(folder, showLoader = false) {
- this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', showLoader);
-
- this.service
- .getFolderContent(folder.folder_path, folder.state)
- .then((response) => this.store.setfolderContent(folder, response.data.environments))
- .then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false))
- .catch(() => {
- createFlash({
- message: s__('Environments|An error occurred while fetching the environments.'),
- });
- this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false);
- });
+ environments() {
+ return this.environmentApp?.environments?.filter((e) => e.size === 1) ?? [];
},
+ hasEnvironments() {
+ return this.environments.length > 0 || this.folders.length > 0;
+ },
+ availableCount() {
+ return this.environmentApp?.availableCount;
+ },
+ addEnvironment() {
+ if (!this.canCreateEnvironment) {
+ return null;
+ }
- successCallback(resp) {
- this.saveData(resp);
-
- // We need to verify if any folder is open to also update it
- const openFolders = this.store.getOpenFolders();
- if (openFolders.length) {
- openFolders.forEach((folder) => this.fetchChildEnvironments(folder));
+ return {
+ text: this.$options.i18n.newEnvironmentButtonLabel,
+ attributes: {
+ href: this.newEnvironmentPath,
+ category: 'primary',
+ variant: 'confirm',
+ },
+ };
+ },
+ openReviewAppModal() {
+ if (!this.canSetupReviewApp) {
+ return null;
}
+
+ return {
+ text: this.$options.i18n.reviewAppButtonLabel,
+ attributes: {
+ category: 'secondary',
+ variant: 'confirm',
+ },
+ };
+ },
+ stoppedCount() {
+ return this.environmentApp?.stoppedCount;
},
+ totalItems() {
+ return this.pageInfo?.total;
+ },
+ itemsPerPage() {
+ return this.pageInfo?.perPage;
+ },
+ },
+ mounted() {
+ window.addEventListener('popstate', this.syncPageFromQueryParams);
},
+ destroyed() {
+ window.removeEventListener('popstate', this.syncPageFromQueryParams);
+ this.$apollo.queries.environmentApp.stopPolling();
+ },
+ methods: {
+ showReviewAppModal() {
+ this.isReviewAppModalVisible = true;
+ },
+ setScope(scope) {
+ this.scope = scope;
+ this.moveToPage(1);
+ },
+ movePage(direction) {
+ this.moveToPage(this.pageInfo[`${direction}Page`]);
+ },
+ moveToPage(page) {
+ this.page = page;
+ updateHistory({
+ url: setUrlParams({ page: this.page }),
+ title: document.title,
+ });
+ this.resetPolling();
+ },
+ syncPageFromQueryParams() {
+ const { page = '1' } = queryToObject(window.location.search);
+ this.page = parseInt(page, 10);
+ },
+ resetPolling() {
+ this.$apollo.queries.environmentApp.stopPolling();
+ this.$apollo.queries.environmentApp.refetch();
+ this.$nextTick(() => {
+ if (this.interval) {
+ this.$apollo.queries.environmentApp.startPolling(this.interval);
+ }
+ });
+ },
+ },
+ ENVIRONMENTS_SCOPE,
};
</script>
<template>
- <div class="environments-section">
- <stop-environment-modal :environment="environmentInStopModal" />
- <delete-environment-modal :environment="environmentInDeleteModal" />
- <confirm-rollback-modal :environment="environmentInRollbackModal" />
-
- <div class="gl-w-full">
- <div class="gl-display-flex gl-flex-direction-column gl-mt-3 gl-md-display-none!">
- <gl-button
- v-if="state.reviewAppDetails.can_setup_review_app"
- v-gl-modal="$options.modal.id"
- data-testid="enable-review-app"
- variant="info"
- category="secondary"
- type="button"
- class="gl-mb-3 gl-flex-grow-1"
- >{{ $options.i18n.reviewAppButtonLabel }}</gl-button
- >
- <gl-button
- v-if="canCreateEnvironment"
- :href="newEnvironmentPath"
- data-testid="new-environment"
- category="primary"
- variant="confirm"
- >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
- >
- </div>
- <gl-tabs :value="activeTab" content-class="gl-display-none">
- <gl-tab
- v-for="(tab, idx) in tabs"
- :key="idx"
- :title-item-class="`js-environments-tab-${tab.scope}`"
- @click="onChangeTab(tab.scope)"
- >
- <template #title>
- <span>{{ tab.name }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
- </template>
- </gl-tab>
- <template #tabs-end>
- <div
- class="gl-display-none gl-md-display-flex gl-lg-align-items-center gl-lg-flex-direction-row gl-lg-flex-fill-1 gl-lg-justify-content-end gl-lg-mt-0"
- >
- <gl-button
- v-if="state.reviewAppDetails.can_setup_review_app"
- v-gl-modal="$options.modal.id"
- data-testid="enable-review-app"
- variant="info"
- category="secondary"
- type="button"
- class="gl-mb-3 gl-lg-mr-3 gl-lg-mb-0"
- >{{ $options.i18n.reviewAppButtonLabel }}</gl-button
- >
- <gl-button
- v-if="canCreateEnvironment"
- :href="newEnvironmentPath"
- data-testid="new-environment"
- category="primary"
- variant="confirm"
- >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
- >
- </div>
+ <div>
+ <enable-review-app-modal
+ v-if="canSetupReviewApp"
+ v-model="isReviewAppModalVisible"
+ :modal-id="$options.modalId"
+ data-testid="enable-review-app-modal"
+ />
+ <delete-environment-modal :environment="environmentToDelete" graphql />
+ <stop-environment-modal :environment="environmentToStop" graphql />
+ <confirm-rollback-modal :environment="environmentToRollback" graphql />
+ <canary-update-modal :environment="environmentToChangeCanary" :weight="weight" />
+ <gl-tabs
+ :action-secondary="addEnvironment"
+ :action-primary="openReviewAppModal"
+ sync-active-tab-with-query-params
+ query-param-name="scope"
+ @primary="showReviewAppModal"
+ >
+ <gl-tab
+ :query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE"
+ @click="setScope($options.ENVIRONMENTS_SCOPE.AVAILABLE)"
+ >
+ <template #title>
+ <span>{{ $options.i18n.available }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">
+ {{ availableCount }}
+ </gl-badge>
</template>
- </gl-tabs>
- <container
- :is-loading="isLoading"
- :environments="state.environments"
- :pagination="state.paginationInformation"
- @onChangePage="onChangePage"
+ </gl-tab>
+ <gl-tab
+ :query-param-value="$options.ENVIRONMENTS_SCOPE.STOPPED"
+ @click="setScope($options.ENVIRONMENTS_SCOPE.STOPPED)"
>
- <template v-if="!isLoading && state.environments.length === 0" #empty-state>
- <empty-state :help-path="helpPagePath" />
+ <template #title>
+ <span>{{ $options.i18n.stopped }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">
+ {{ stoppedCount }}
+ </gl-badge>
</template>
- </container>
- <enable-review-app-modal
- v-if="state.reviewAppDetails.can_setup_review_app"
- :modal-id="$options.modal.id"
- data-testid="enable-review-app-modal"
+ </gl-tab>
+ </gl-tabs>
+ <template v-if="hasEnvironments">
+ <environment-folder
+ v-for="folder in folders"
+ :key="folder.name"
+ class="gl-mb-3"
+ :scope="scope"
+ :nested-environment="folder"
+ />
+ <environment-item
+ v-for="environment in environments"
+ :key="environment.name"
+ class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid"
+ :environment="environment.latest"
+ @change="resetPolling"
/>
- </div>
+ </template>
+ <empty-state v-else :help-path="helpPagePath" />
+ <gl-pagination
+ align="center"
+ :total-items="totalItems"
+ :per-page="itemsPerPage"
+ :value="page"
+ :next="$options.i18n.next"
+ :prev="$options.i18n.prev"
+ :label-previous-page="$options.prevPage"
+ :label-next-page="$options.nextPage"
+ :label-page="$options.goto"
+ @next="movePage('next')"
+ @previous="movePage('previous')"
+ @input="moveToPage"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 27a763fb9c4..f35fabccae7 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -40,6 +40,9 @@ export default {
Terminal,
TimeAgoTooltip,
Delete,
+ EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'),
+ EnvironmentApproval: () =>
+ import('ee_component/environments/components/environment_approval.vue'),
},
directives: {
GlTooltip,
@@ -97,6 +100,9 @@ export default {
hasDeployment() {
return Boolean(this.environment?.upcomingDeployment || this.environment?.lastDeployment);
},
+ hasOpenedAlert() {
+ return this.environment?.hasOpenedAlert;
+ },
actions() {
if (!this.lastDeployment) {
return [];
@@ -296,12 +302,20 @@ export default {
class="gl-pl-4"
/>
</div>
- <div v-if="upcomingDeployment" :class="$options.deploymentClasses">
+ <div
+ v-if="upcomingDeployment"
+ :class="$options.deploymentClasses"
+ data-testid="upcoming-deployment-content"
+ >
<deployment
:deployment="upcomingDeployment"
:class="{ 'gl-ml-7': inFolder }"
class="gl-pl-4"
- />
+ >
+ <template #approval>
+ <environment-approval :environment="environment" @change="$emit('change')" />
+ </template>
+ </deployment>
</div>
</template>
<div v-else :class="$options.deploymentClasses">
@@ -319,6 +333,9 @@ export default {
class="gl-pl-4"
/>
</div>
+ <div v-if="hasOpenedAlert" class="gl-bg-gray-10 gl-md-px-7">
+ <environment-alert :environment="environment" class="gl-pl-4 gl-py-5" />
+ </div>
</gl-collapse>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue
deleted file mode 100644
index 3699f39b611..00000000000
--- a/app/assets/javascripts/environments/components/new_environments_app.vue
+++ /dev/null
@@ -1,252 +0,0 @@
-<script>
-import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
-import { s__, __, sprintf } from '~/locale';
-import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
-import environmentAppQuery from '../graphql/queries/environment_app.query.graphql';
-import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql';
-import pageInfoQuery from '../graphql/queries/page_info.query.graphql';
-import environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql';
-import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql';
-import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql';
-import environmentToChangeCanaryQuery from '../graphql/queries/environment_to_change_canary.query.graphql';
-import EnvironmentFolder from './new_environment_folder.vue';
-import EnableReviewAppModal from './enable_review_app_modal.vue';
-import StopEnvironmentModal from './stop_environment_modal.vue';
-import EnvironmentItem from './new_environment_item.vue';
-import ConfirmRollbackModal from './confirm_rollback_modal.vue';
-import DeleteEnvironmentModal from './delete_environment_modal.vue';
-import CanaryUpdateModal from './canary_update_modal.vue';
-
-export default {
- components: {
- DeleteEnvironmentModal,
- CanaryUpdateModal,
- ConfirmRollbackModal,
- EnvironmentFolder,
- EnableReviewAppModal,
- EnvironmentItem,
- StopEnvironmentModal,
- GlBadge,
- GlPagination,
- GlTab,
- GlTabs,
- },
- apollo: {
- environmentApp: {
- query: environmentAppQuery,
- variables() {
- return {
- scope: this.scope,
- page: this.page ?? 1,
- };
- },
- pollInterval() {
- return this.interval;
- },
- },
- interval: {
- query: pollIntervalQuery,
- },
- pageInfo: {
- query: pageInfoQuery,
- },
- environmentToDelete: {
- query: environmentToDeleteQuery,
- },
- environmentToRollback: {
- query: environmentToRollbackQuery,
- },
- environmentToStop: {
- query: environmentToStopQuery,
- },
- environmentToChangeCanary: {
- query: environmentToChangeCanaryQuery,
- },
- weight: {
- query: environmentToChangeCanaryQuery,
- },
- },
- inject: ['newEnvironmentPath', 'canCreateEnvironment'],
- i18n: {
- newEnvironmentButtonLabel: s__('Environments|New environment'),
- reviewAppButtonLabel: s__('Environments|Enable review app'),
- available: __('Available'),
- stopped: __('Stopped'),
- prevPage: __('Go to previous page'),
- nextPage: __('Go to next page'),
- next: __('Next'),
- prev: __('Prev'),
- goto: (page) => sprintf(__('Go to page %{page}'), { page }),
- },
- modalId: 'enable-review-app-info',
- data() {
- const { page = '1', scope = 'available' } = queryToObject(window.location.search);
- return {
- interval: undefined,
- isReviewAppModalVisible: false,
- page: parseInt(page, 10),
- scope,
- environmentToDelete: {},
- environmentToRollback: {},
- environmentToStop: {},
- environmentToChangeCanary: {},
- weight: 0,
- };
- },
- computed: {
- canSetupReviewApp() {
- return this.environmentApp?.reviewApp?.canSetupReviewApp;
- },
- folders() {
- return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? [];
- },
- environments() {
- return this.environmentApp?.environments?.filter((e) => e.size === 1) ?? [];
- },
- availableCount() {
- return this.environmentApp?.availableCount;
- },
- addEnvironment() {
- if (!this.canCreateEnvironment) {
- return null;
- }
-
- return {
- text: this.$options.i18n.newEnvironmentButtonLabel,
- attributes: {
- href: this.newEnvironmentPath,
- category: 'primary',
- variant: 'confirm',
- },
- };
- },
- openReviewAppModal() {
- if (!this.canSetupReviewApp) {
- return null;
- }
-
- return {
- text: this.$options.i18n.reviewAppButtonLabel,
- attributes: {
- category: 'secondary',
- variant: 'confirm',
- },
- };
- },
- stoppedCount() {
- return this.environmentApp?.stoppedCount;
- },
- totalItems() {
- return this.pageInfo?.total;
- },
- itemsPerPage() {
- return this.pageInfo?.perPage;
- },
- },
- mounted() {
- window.addEventListener('popstate', this.syncPageFromQueryParams);
- },
- destroyed() {
- window.removeEventListener('popstate', this.syncPageFromQueryParams);
- this.$apollo.queries.environmentApp.stopPolling();
- },
- methods: {
- showReviewAppModal() {
- this.isReviewAppModalVisible = true;
- },
- setScope(scope) {
- this.scope = scope;
- this.moveToPage(1);
- },
- movePage(direction) {
- this.moveToPage(this.pageInfo[`${direction}Page`]);
- },
- moveToPage(page) {
- this.page = page;
- updateHistory({
- url: setUrlParams({ page: this.page }),
- title: document.title,
- });
- this.resetPolling();
- },
- syncPageFromQueryParams() {
- const { page = '1' } = queryToObject(window.location.search);
- this.page = parseInt(page, 10);
- },
- resetPolling() {
- this.$apollo.queries.environmentApp.stopPolling();
- this.$nextTick(() => {
- if (this.interval) {
- this.$apollo.queries.environmentApp.startPolling(this.interval);
- } else {
- this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page });
- }
- });
- },
- },
-};
-</script>
-<template>
- <div>
- <enable-review-app-modal
- v-if="canSetupReviewApp"
- v-model="isReviewAppModalVisible"
- :modal-id="$options.modalId"
- data-testid="enable-review-app-modal"
- />
- <delete-environment-modal :environment="environmentToDelete" graphql />
- <stop-environment-modal :environment="environmentToStop" graphql />
- <confirm-rollback-modal :environment="environmentToRollback" graphql />
- <canary-update-modal :environment="environmentToChangeCanary" :weight="weight" />
- <gl-tabs
- :action-secondary="addEnvironment"
- :action-primary="openReviewAppModal"
- sync-active-tab-with-query-params
- query-param-name="scope"
- @primary="showReviewAppModal"
- >
- <gl-tab query-param-value="available" @click="setScope('available')">
- <template #title>
- <span>{{ $options.i18n.available }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">
- {{ availableCount }}
- </gl-badge>
- </template>
- </gl-tab>
- <gl-tab query-param-value="stopped" @click="setScope('stopped')">
- <template #title>
- <span>{{ $options.i18n.stopped }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">
- {{ stoppedCount }}
- </gl-badge>
- </template>
- </gl-tab>
- </gl-tabs>
- <environment-folder
- v-for="folder in folders"
- :key="folder.name"
- class="gl-mb-3"
- :nested-environment="folder"
- />
- <environment-item
- v-for="environment in environments"
- :key="environment.name"
- class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid"
- :environment="environment.latest"
- />
- <gl-pagination
- align="center"
- :total-items="totalItems"
- :per-page="itemsPerPage"
- :value="page"
- :next="$options.i18n.next"
- :prev="$options.i18n.prev"
- :label-previous-page="$options.prevPage"
- :label-next-page="$options.nextPage"
- :label-page="$options.goto"
- @next="movePage('next')"
- @previous="movePage('previous')"
- @input="moveToPage"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index 6d427bef4e6..942491039d6 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -38,3 +38,13 @@ export const CANARY_STATUS = {
};
export const CANARY_UPDATE_MODAL = 'confirm-canary-change';
+
+export const ENVIRONMENTS_SCOPE = {
+ AVAILABLE: 'available',
+ STOPPED: 'stopped',
+};
+
+export const ENVIRONMENT_COUNT_BY_SCOPE = {
+ [ENVIRONMENTS_SCOPE.AVAILABLE]: 'availableCount',
+ [ENVIRONMENTS_SCOPE.STOPPED]: 'stoppedCount',
+};
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
index 64b18c2003b..26514b59995 100644
--- a/app/assets/javascripts/environments/graphql/client.js
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -2,6 +2,9 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import environmentApp from './queries/environment_app.query.graphql';
import pageInfoQuery from './queries/page_info.query.graphql';
+import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
+import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
+import environmentToStopQuery from './queries/environment_to_stop.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
@@ -33,6 +36,52 @@ export const apolloProvider = (endpoint) => {
},
},
});
+
+ cache.writeQuery({
+ query: environmentToDeleteQuery,
+ data: {
+ environmentToDelete: {
+ name: 'null',
+ __typename: 'LocalEnvironment',
+ id: '0',
+ deletePath: null,
+ folderPath: null,
+ retryUrl: null,
+ autoStopPath: null,
+ lastDeployment: null,
+ },
+ },
+ });
+ cache.writeQuery({
+ query: environmentToStopQuery,
+ data: {
+ environmentToStop: {
+ name: 'null',
+ __typename: 'LocalEnvironment',
+ id: '0',
+ deletePath: null,
+ folderPath: null,
+ retryUrl: null,
+ autoStopPath: null,
+ lastDeployment: null,
+ },
+ },
+ });
+ cache.writeQuery({
+ query: environmentToRollbackQuery,
+ data: {
+ environmentToRollback: {
+ name: 'null',
+ __typename: 'LocalEnvironment',
+ id: '0',
+ deletePath: null,
+ folderPath: null,
+ retryUrl: null,
+ autoStopPath: null,
+ lastDeployment: null,
+ },
+ },
+ });
return new VueApollo({
defaultClient,
});
diff --git a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql
index 3292c916b2e..e8c145ee916 100644
--- a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql
@@ -1,5 +1,5 @@
-query getEnvironmentFolder($environment: NestedLocalEnvironment) {
- folder(environment: $environment) @client {
+query getEnvironmentFolder($environment: NestedLocalEnvironment, $scope: String) {
+ folder(environment: $environment, scope: $scope) @client {
availableCount
environments
stoppedCount
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index dc763b77157..a7866c1e778 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -11,6 +11,7 @@ import environmentToRollbackQuery from './queries/environment_to_rollback.query.
import environmentToStopQuery from './queries/environment_to_stop.query.graphql';
import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
import environmentToChangeCanaryQuery from './queries/environment_to_change_canary.query.graphql';
+import isEnvironmentStoppingQuery from './queries/is_environment_stopping.query.graphql';
import pageInfoQuery from './queries/page_info.query.graphql';
const buildErrors = (errors = []) => ({
@@ -58,8 +59,8 @@ export const resolvers = (endpoint) => ({
};
});
},
- folder(_, { environment: { folderPath } }) {
- return axios.get(folderPath, { params: { per_page: 3 } }).then((res) => ({
+ folder(_, { environment: { folderPath }, scope }) {
+ return axios.get(folderPath, { params: { scope, per_page: 3 } }).then((res) => ({
availableCount: res.data.available_count,
environments: res.data.environments.map(mapEnvironment),
stoppedCount: res.data.stopped_count,
@@ -71,11 +72,21 @@ export const resolvers = (endpoint) => ({
},
},
Mutation: {
- stopEnvironment(_, { environment }) {
+ stopEnvironment(_, { environment }, { client }) {
+ client.writeQuery({
+ query: isEnvironmentStoppingQuery,
+ variables: { environment },
+ data: { isEnvironmentStopping: true },
+ });
return axios
.post(environment.stopPath)
.then(() => buildErrors())
.catch(() => {
+ client.writeQuery({
+ query: isEnvironmentStoppingQuery,
+ variables: { environment },
+ data: { isEnvironmentStopping: false },
+ });
return buildErrors([
s__('Environments|An error occurred while stopping the environment, please try again'),
]);
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index 3b1d35c1f22..d9a523fd806 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -1,48 +1,37 @@
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 environmentsComponent from './components/environments_app.vue';
+import { apolloProvider } from './graphql/client';
+import EnvironmentsApp from './components/environments_app.vue';
-Vue.use(Translate);
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
export default (el) => {
if (el) {
+ const {
+ canCreateEnvironment,
+ endpoint,
+ newEnvironmentPath,
+ helpPagePath,
+ projectPath,
+ defaultBranchName,
+ projectId,
+ } = el.dataset;
+
return new Vue({
el,
- components: {
- environmentsComponent,
- },
- apolloProvider,
+ apolloProvider: apolloProvider(endpoint),
provide: {
- projectPath: el.dataset.projectPath,
- defaultBranchName: el.dataset.defaultBranchName,
- },
- data() {
- const environmentsData = el.dataset;
-
- return {
- endpoint: environmentsData.environmentsDataEndpoint,
- newEnvironmentPath: environmentsData.newEnvironmentPath,
- helpPagePath: environmentsData.helpPagePath,
- canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment),
- };
+ projectPath,
+ defaultBranchName,
+ endpoint,
+ newEnvironmentPath,
+ helpPagePath,
+ projectId,
+ canCreateEnvironment: parseBoolean(canCreateEnvironment),
},
- render(createElement) {
- return createElement('environments-component', {
- props: {
- endpoint: this.endpoint,
- newEnvironmentPath: this.newEnvironmentPath,
- helpPagePath: this.helpPagePath,
- canCreateEnvironment: this.canCreateEnvironment,
- },
- });
+ render(h) {
+ return h(EnvironmentsApp);
},
});
}
diff --git a/app/assets/javascripts/environments/new_index.js b/app/assets/javascripts/environments/new_index.js
deleted file mode 100644
index dd5c709c75a..00000000000
--- a/app/assets/javascripts/environments/new_index.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import { parseBoolean } from '../lib/utils/common_utils';
-import { apolloProvider } from './graphql/client';
-import EnvironmentsApp from './components/new_environments_app.vue';
-
-Vue.use(VueApollo);
-
-export default (el) => {
- if (el) {
- const {
- canCreateEnvironment,
- endpoint,
- newEnvironmentPath,
- helpPagePath,
- projectPath,
- defaultBranchName,
- } = el.dataset;
-
- return new Vue({
- el,
- apolloProvider: apolloProvider(endpoint),
- provide: {
- projectPath,
- defaultBranchName,
- endpoint,
- newEnvironmentPath,
- helpPagePath,
- canCreateEnvironment: parseBoolean(canCreateEnvironment),
- },
- render(h) {
- return h(EnvironmentsApp);
- },
- });
- }
-
- return null;
-};
diff --git a/app/assets/javascripts/error_tracking/components/constants.js b/app/assets/javascripts/error_tracking/components/constants.js
deleted file mode 100644
index 41b952e26d8..00000000000
--- a/app/assets/javascripts/error_tracking/components/constants.js
+++ /dev/null
@@ -1,21 +0,0 @@
-export const severityLevel = {
- FATAL: 'fatal',
- ERROR: 'error',
- WARNING: 'warning',
- INFO: 'info',
- DEBUG: 'debug',
-};
-
-export const severityLevelVariant = {
- [severityLevel.FATAL]: 'danger',
- [severityLevel.ERROR]: 'neutral',
- [severityLevel.WARNING]: 'warning',
- [severityLevel.INFO]: 'info',
- [severityLevel.DEBUG]: 'muted',
-};
-
-export const errorStatus = {
- IGNORED: 'ignored',
- RESOLVED: 'resolved',
- UNRESOLVED: 'unresolved',
-};
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index e00fec6fddf..0a8abdc90c6 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -26,7 +26,7 @@ import {
trackErrorStatusUpdateOptions,
} from '../utils';
-import { severityLevel, severityLevelVariant, errorStatus } from './constants';
+import { severityLevel, severityLevelVariant, errorStatus } from '../constants';
import Stacktrace from './stacktrace.vue';
const SENTRY_TIMEOUT = 10000;
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 5db8c8cf8d3..3d540d46b3c 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlAlert,
GlEmptyState,
GlButton,
GlIcon,
@@ -10,6 +11,7 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
+ GlSprintf,
GlTooltipDirective,
GlPagination,
} from '@gitlab/ui';
@@ -21,6 +23,7 @@ import { __ } from '~/locale';
import Tracking from '~/tracking';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '../utils';
+import { I18N_ERROR_TRACKING_LIST } from '../constants';
import ErrorTrackingActions from './error_tracking_actions.vue';
export const tableDataClass = 'table-col d-flex d-md-table-cell align-items-center';
@@ -29,6 +32,7 @@ export default {
FIRST_PAGE: 1,
PREV_PAGE: 1,
NEXT_PAGE: 2,
+ i18n: I18N_ERROR_TRACKING_LIST,
fields: [
{
key: 'error',
@@ -71,6 +75,7 @@ export default {
frequency: __('Frequency'),
},
components: {
+ GlAlert,
GlEmptyState,
GlButton,
GlDropdown,
@@ -81,6 +86,7 @@ export default {
GlLoadingIcon,
GlTable,
GlFormInput,
+ GlSprintf,
GlPagination,
TimeAgo,
ErrorTrackingActions,
@@ -117,12 +123,17 @@ export default {
type: String,
required: true,
},
+ showIntegratedTrackingDisabledAlert: {
+ type: Boolean,
+ required: false,
+ },
},
hasLocalStorage: AccessorUtils.canUseLocalStorage(),
data() {
return {
errorSearchQuery: '',
pageValue: this.$options.FIRST_PAGE,
+ isAlertDismissed: false,
};
},
computed: {
@@ -142,6 +153,9 @@ export default {
errorTrackingHelpUrl() {
return helpPagePath('operations/error_tracking');
},
+ showIntegratedDisabledAlert() {
+ return !this.isAlertDismissed && this.showIntegratedTrackingDisabledAlert;
+ },
},
watch: {
pagination() {
@@ -150,6 +164,8 @@ export default {
}
},
},
+ epicLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/353639',
+ featureFlagLink: helpPagePath('operations/error_tracking'),
created() {
if (this.errorTrackingEnabled) {
this.setEndpoint(this.indexPath);
@@ -232,6 +248,34 @@ export default {
<template>
<div class="error-list">
<div v-if="errorTrackingEnabled">
+ <gl-alert
+ v-if="showIntegratedDisabledAlert"
+ variant="danger"
+ data-testid="integrated-disabled-alert"
+ @dismiss="isAlertDismissed = true"
+ >
+ <gl-sprintf :message="this.$options.i18n.integratedErrorTrackingDisabledText">
+ <template #epicLink="{ content }">
+ <gl-link :href="$options.epicLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #flagLink="{ content }">
+ <gl-link :href="$options.featureFlagLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #settingsLink="{ content }">
+ <gl-link :href="enableErrorTrackingLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ <div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="enableErrorTrackingLink"
+ class="gl-mr-auto gl-mt-3"
+ >
+ {{ $options.i18n.viewProjectSettingsButton }}
+ </gl-button>
+ </div>
+ </gl-alert>
<div
class="row flex-column flex-md-row align-items-md-center m-0 mt-sm-2 p-3 p-sm-3 bg-secondary border"
>
diff --git a/app/assets/javascripts/error_tracking/constants.js b/app/assets/javascripts/error_tracking/constants.js
new file mode 100644
index 00000000000..f01bac2e81d
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/constants.js
@@ -0,0 +1,30 @@
+import { s__ } from '~/locale';
+
+export const severityLevel = {
+ FATAL: 'fatal',
+ ERROR: 'error',
+ WARNING: 'warning',
+ INFO: 'info',
+ DEBUG: 'debug',
+};
+
+export const severityLevelVariant = {
+ [severityLevel.FATAL]: 'danger',
+ [severityLevel.ERROR]: 'neutral',
+ [severityLevel.WARNING]: 'warning',
+ [severityLevel.INFO]: 'info',
+ [severityLevel.DEBUG]: 'muted',
+};
+
+export const errorStatus = {
+ IGNORED: 'ignored',
+ RESOLVED: 'resolved',
+ UNRESOLVED: 'unresolved',
+};
+
+export const I18N_ERROR_TRACKING_LIST = {
+ integratedErrorTrackingDisabledText: s__(
+ 'ErrorTracking|Integrated error tracking is %{epicLinkStart}turned off by default%{epicLinkEnd} and no longer active for this project. To re-enable error tracking on self-hosted instances, you can either %{flagLinkStart}turn on the feature flag%{flagLinkEnd} for integrated error tracking, or provide a %{settingsLinkStart}Sentry API URL and Auth Token%{settingsLinkEnd} on your project settings page. However, error tracking is not ready for production use and cannot be enabled on GitLab.com.',
+ ),
+ viewProjectSettingsButton: s__('ErrorTracking|View project settings'),
+};
diff --git a/app/assets/javascripts/error_tracking/list.js b/app/assets/javascripts/error_tracking/list.js
index 9c729407009..8b2086e1522 100644
--- a/app/assets/javascripts/error_tracking/list.js
+++ b/app/assets/javascripts/error_tracking/list.js
@@ -14,10 +14,15 @@ export default () => {
projectPath,
listPath,
} = domEl.dataset;
- let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset;
+ let {
+ errorTrackingEnabled,
+ userCanEnableErrorTracking,
+ showIntegratedTrackingDisabledAlert,
+ } = domEl.dataset;
errorTrackingEnabled = parseBoolean(errorTrackingEnabled);
userCanEnableErrorTracking = parseBoolean(userCanEnableErrorTracking);
+ showIntegratedTrackingDisabledAlert = parseBoolean(showIntegratedTrackingDisabledAlert);
// eslint-disable-next-line no-new
new Vue({
@@ -36,6 +41,7 @@ export default () => {
userCanEnableErrorTracking,
projectPath,
listPath,
+ showIntegratedTrackingDisabledAlert,
},
});
},
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
index 4808cd1d1c0..e850d954e0a 100644
--- a/app/assets/javascripts/error_tracking_settings/components/app.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -1,29 +1,40 @@
<script>
import {
+ GlAlert,
GlButton,
GlFormGroup,
GlFormCheckbox,
GlFormRadioGroup,
GlFormRadio,
GlFormInputGroup,
+ GlLink,
+ GlSprintf,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { I18N_ERROR_TRACKING_SETTINGS } from '../constants';
import ErrorTrackingForm from './error_tracking_form.vue';
import ProjectDropdown from './project_dropdown.vue';
export default {
+ i18n: I18N_ERROR_TRACKING_SETTINGS,
components: {
ErrorTrackingForm,
+ GlAlert,
GlButton,
GlFormCheckbox,
GlFormGroup,
GlFormRadioGroup,
GlFormRadio,
GlFormInputGroup,
+ GlLink,
+ GlSprintf,
ProjectDropdown,
ClipboardButton,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
initialApiHost: {
type: String,
@@ -62,6 +73,11 @@ export default {
default: null,
},
},
+ data() {
+ return {
+ isAlertDismissed: false,
+ };
+ },
computed: {
...mapGetters([
'dropdownLabel',
@@ -81,12 +97,34 @@ export default {
showGitlabDsnSetting() {
return this.integrated && this.enabled && this.gitlabDsn;
},
+ showIntegratedErrorTracking() {
+ return this.glFeatures.integratedErrorTracking === true;
+ },
+ setInitialEnabled() {
+ if (this.showIntegratedErrorTracking) {
+ return this.initialEnabled;
+ }
+ if (this.initialIntegrated === 'true') {
+ return 'false';
+ }
+ return this.initialEnabled;
+ },
+ showIntegratedTrackingDisabledAlert() {
+ return (
+ !this.isAlertDismissed &&
+ !this.showIntegratedErrorTracking &&
+ this.initialIntegrated === 'true' &&
+ this.initialEnabled === 'true'
+ );
+ },
},
+ epicLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/353639',
+ featureFlagLink: helpPagePath('operations/error_tracking'),
created() {
this.setInitialState({
apiHost: this.initialApiHost,
- enabled: this.initialEnabled,
- integrated: this.initialIntegrated,
+ enabled: this.setInitialEnabled,
+ integrated: this.showIntegratedErrorTracking && this.initialIntegrated,
project: this.initialProject,
token: this.initialToken,
listProjectsEndpoint: this.listProjectsEndpoint,
@@ -104,21 +142,41 @@ export default {
handleSubmit() {
this.updateSettings();
},
+ dismissAlert() {
+ this.isAlertDismissed = true;
+ },
},
};
</script>
<template>
<div>
+ <gl-alert v-if="showIntegratedTrackingDisabledAlert" variant="danger" @dismiss="dismissAlert">
+ <gl-sprintf :message="this.$options.i18n.integratedErrorTrackingDisabledText">
+ <template #epicLink="{ content }">
+ <gl-link :href="$options.epicLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #flagLink="{ content }">
+ <gl-link :href="$options.featureFlagLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
<gl-form-group
:label="s__('ErrorTracking|Enable error tracking')"
label-for="error-tracking-enabled"
>
- <gl-form-checkbox id="error-tracking-enabled" :checked="enabled" @change="updateEnabled">
+ <gl-form-checkbox
+ id="error-tracking-enabled"
+ :checked="enabled"
+ data-testid="error-tracking-enabled"
+ @change="updateEnabled"
+ >
{{ s__('ErrorTracking|Active') }}
</gl-form-checkbox>
</gl-form-group>
<gl-form-group
+ v-if="showIntegratedErrorTracking"
:label="s__('ErrorTracking|Error tracking backend')"
data-testid="tracking-backend-settings"
>
diff --git a/app/assets/javascripts/error_tracking_settings/constants.js b/app/assets/javascripts/error_tracking_settings/constants.js
new file mode 100644
index 00000000000..ee86c55e843
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/constants.js
@@ -0,0 +1,7 @@
+import { s__ } from '~/locale';
+
+export const I18N_ERROR_TRACKING_SETTINGS = {
+ integratedErrorTrackingDisabledText: s__(
+ 'ErrorTracking|Integrated error tracking is %{epicLinkStart}turned off by default%{epicLinkEnd} and no longer active for this project. To re-enable error tracking on self-hosted instances, you can either %{flagLinkStart}turn on the feature flag%{flagLinkEnd} for integrated error tracking, or provide a Sentry API URL and Auth Token below. However, error tracking is not ready for production use and cannot be enabled on GitLab.com.',
+ ),
+};
diff --git a/app/assets/javascripts/experimentation/components/gitlab_experiment.vue b/app/assets/javascripts/experimentation/components/gitlab_experiment.vue
index 294dbf77991..678ce447e80 100644
--- a/app/assets/javascripts/experimentation/components/gitlab_experiment.vue
+++ b/app/assets/javascripts/experimentation/components/gitlab_experiment.vue
@@ -9,7 +9,7 @@ export default {
},
},
render() {
- return this.$slots?.[getExperimentVariant(this.name)];
+ return this.$scopedSlots?.[getExperimentVariant(this.name)]?.();
},
};
</script>
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index fcc7caa9ff2..9de291b7809 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,3 +1,4 @@
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { FILTER_TYPE } from './constants';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
@@ -13,7 +14,7 @@ export default class FilteredSearchDropdown {
this.filter = filter;
this.dropdown = dropdown;
this.loadingTemplate = `<div class="filter-dropdown-loading">
- <span class="spinner"></span>
+ ${loadingIconForLegacyJS().outerHTML}
</div>`;
this.bindEvents();
}
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index bf29a356abd..8cb2e9e249b 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -3,6 +3,7 @@ import '~/lib/utils/jquery_at_who';
import { escape as lodashEscape, sortBy, template, escapeRegExp } from 'lodash';
import * as Emoji from '~/emoji';
import axios from '~/lib/utils/axios_utils';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { s__, __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
@@ -574,6 +575,10 @@ class GfmAutoComplete {
// Do not match if there is no `~` before the cursor
return null;
}
+ if (subtext.endsWith('~~')) {
+ // Do not match if there are two consecutive `~` characters (strikethrough) before the cursor
+ return null;
+ }
const lastCandidate = subtext.split(flag).pop();
if (labels.find((label) => label.title.startsWith(lastCandidate))) {
return lastCandidate;
@@ -953,9 +958,14 @@ GfmAutoComplete.Contacts = {
return `<li><small>${firstName} ${lastName}</small> ${escape(email)}</li>`;
},
};
+
+const loadingSpinner = loadingIconForLegacyJS({
+ inline: true,
+ classes: ['gl-mr-2'],
+}).outerHTML;
+
GfmAutoComplete.Loading = {
- template:
- '<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>',
+ template: `<li style="pointer-events: none;">${loadingSpinner}Loading...</li>`,
};
export default GfmAutoComplete;
diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue
index 64784755b66..03b256297f6 100644
--- a/app/assets/javascripts/google_cloud/components/app.vue
+++ b/app/assets/javascripts/google_cloud/components/app.vue
@@ -4,6 +4,7 @@ import { __ } from '~/locale';
import Home from './home.vue';
import IncubationBanner from './incubation_banner.vue';
import ServiceAccountsForm from './service_accounts_form.vue';
+import GcpRegionsForm from './gcp_regions_form.vue';
import NoGcpProjects from './errors/no_gcp_projects.vue';
import GcpError from './errors/gcp_error.vue';
@@ -11,6 +12,7 @@ const SCREEN_GCP_ERROR = 'gcp_error';
const SCREEN_HOME = 'home';
const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects';
const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form';
+const SCREEN_GCP_REGIONS_FORM = 'gcp_regions_form';
export default {
components: {
@@ -34,6 +36,8 @@ export default {
return NoGcpProjects;
case SCREEN_SERVICE_ACCOUNTS_FORM:
return ServiceAccountsForm;
+ case SCREEN_GCP_REGIONS_FORM:
+ return GcpRegionsForm;
default:
throw new Error(__('Unknown screen'));
}
diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue b/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue
new file mode 100644
index 00000000000..23011e5a5b0
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue
@@ -0,0 +1,62 @@
+<script>
+import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+
+export default {
+ components: { GlButton, GlFormGroup, GlFormSelect },
+ props: {
+ availableRegions: { required: true, type: Array },
+ refs: { required: true, type: Array },
+ cancelPath: { required: true, type: String },
+ },
+ i18n: {
+ title: __('Configure region for environment'),
+ gcpRegionLabel: __('Region'),
+ gcpRegionDescription: __('List of suitable GCP locations'),
+ refsLabel: s__('GoogleCloud|Refs'),
+ refsDescription: s__('GoogleCloud|Configured region is linked to the selected branch or tag'),
+ submitLabel: __('Configure region'),
+ cancelLabel: __('Cancel'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid">
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ </header>
+
+ <gl-form-group
+ label-for="ref"
+ :label="$options.i18n.refsLabel"
+ :description="$options.i18n.refsDescription"
+ >
+ <gl-form-select id="ref" name="ref" required>
+ <option value="*">{{ __('All') }}</option>
+ <option v-for="ref in refs" :key="ref" :value="ref">
+ {{ ref }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ label-for="gcp_region"
+ :label="$options.i18n.gcpRegionLabel"
+ :description="$options.i18n.gcpRegionDescription"
+ >
+ <gl-form-select id="gcp_region" name="gcp_region" required :list="availableRegions">
+ <option v-for="(region, index) in availableRegions" :key="index" :value="region">
+ {{ region }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <div class="form-actions row">
+ <gl-button type="submit" category="primary" variant="confirm">
+ {{ $options.i18n.submitLabel }}
+ </gl-button>
+ <gl-button class="gl-ml-1" :href="cancelPath">{{ $options.i18n.cancelLabel }}</gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue b/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue
new file mode 100644
index 00000000000..1cc5a85198a
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: { GlButton, GlEmptyState, GlTable },
+ props: {
+ list: {
+ type: Array,
+ required: true,
+ },
+ createUrl: {
+ type: String,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ tableFields: [
+ { key: 'environment', label: __('Environment'), sortable: true },
+ { key: 'gcp_region', label: __('Region'), sortable: true },
+ ],
+ i18n: {
+ emptyStateTitle: __('No regions configured'),
+ description: __('Configure your environments to be deployed to specific geographical regions'),
+ emptyStateAction: __('Add a GCP region'),
+ configureRegions: __('Configure regions'),
+ listTitle: __('Regions'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-empty-state
+ v-if="list.length === 0"
+ :title="$options.i18n.emptyStateTitle"
+ :description="$options.i18n.description"
+ :primary-button-link="createUrl"
+ :primary-button-text="$options.i18n.configureRegions"
+ />
+
+ <div v-else>
+ <h2 class="gl-font-size-h2">{{ $options.i18n.listTitle }}</h2>
+ <p>{{ $options.i18n.description }}</p>
+
+ <gl-table :items="list" :fields="$options.tableFields" />
+
+ <gl-button :href="createUrl" category="primary" variant="info">
+ {{ $options.i18n.configureRegions }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue
index c08d8bb7c51..e41337e2679 100644
--- a/app/assets/javascripts/google_cloud/components/home.vue
+++ b/app/assets/javascripts/google_cloud/components/home.vue
@@ -1,14 +1,18 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
import DeploymentsServiceTable from './deployments_service_table.vue';
+import RevokeOauth from './revoke_oauth.vue';
import ServiceAccountsList from './service_accounts_list.vue';
+import GcpRegionsList from './gcp_regions_list.vue';
export default {
components: {
GlTabs,
GlTab,
DeploymentsServiceTable,
+ RevokeOauth,
ServiceAccountsList,
+ GcpRegionsList,
},
props: {
serviceAccounts: {
@@ -19,6 +23,10 @@ export default {
type: String,
required: true,
},
+ configureGcpRegionsUrl: {
+ type: String,
+ required: true,
+ },
emptyIllustrationUrl: {
type: String,
required: true,
@@ -31,6 +39,14 @@ export default {
type: String,
required: true,
},
+ gcpRegions: {
+ type: Array,
+ required: true,
+ },
+ revokeOauthUrl: {
+ type: String,
+ required: true,
+ },
},
};
</script>
@@ -44,6 +60,15 @@ export default {
:create-url="createServiceAccountUrl"
:empty-illustration-url="emptyIllustrationUrl"
/>
+ <hr />
+ <gcp-regions-list
+ class="gl-mx-4"
+ :empty-illustration-url="emptyIllustrationUrl"
+ :create-url="configureGcpRegionsUrl"
+ :list="gcpRegions"
+ />
+ <hr v-if="revokeOauthUrl" />
+ <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" />
</gl-tab>
<gl-tab :title="__('Deployments')">
<deployments-service-table
diff --git a/app/assets/javascripts/google_cloud/components/revoke_oauth.vue b/app/assets/javascripts/google_cloud/components/revoke_oauth.vue
new file mode 100644
index 00000000000..07d966894f6
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/revoke_oauth.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlButton, GlForm } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { s__ } from '~/locale';
+
+export const GOOGLE_CLOUD_REVOKE_TITLE = s__('GoogleCloud|Revoke authorizations');
+export const GOOGLE_CLOUD_REVOKE_DESCRIPTION = s__(
+ 'GoogleCloud|Revoke authorizations granted to GitLab. This does not invalidate service accounts.',
+);
+
+export default {
+ components: { GlButton, GlForm },
+ csrf,
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ title: GOOGLE_CLOUD_REVOKE_TITLE,
+ description: GOOGLE_CLOUD_REVOKE_DESCRIPTION,
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mx-4">
+ <h2 class="gl-font-size-h2">{{ $options.i18n.title }}</h2>
+ <p>{{ $options.i18n.description }}</p>
+ <gl-form :action="url" method="post">
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <gl-button category="secondary" variant="warning" type="submit">
+ {{ $options.i18n.title }}
+ </gl-button>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
index 551783e6c50..faec94e735b 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
@@ -1,26 +1,29 @@
<script>
-import { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlButton, GlFormCheckbox, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { s__ } from '~/locale';
export default {
+ ALL_REFS: '*',
components: { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox },
props: {
gcpProjects: { required: true, type: Array },
- environments: { required: true, type: Array },
+ refs: { required: true, type: Array },
cancelPath: { required: true, type: String },
},
i18n: {
- title: __('Create service account'),
- gcpProjectLabel: __('Google Cloud project'),
- gcpProjectDescription: __(
- 'New service account is generated for the selected Google Cloud project',
+ title: s__('GoogleCloud|Create service account'),
+ gcpProjectLabel: s__('GoogleCloud|Google Cloud project'),
+ gcpProjectDescription: s__(
+ 'GoogleCloud|New service account is generated for the selected Google Cloud project',
),
- environmentLabel: __('Environment'),
- environmentDescription: __('Generated service account is linked to the selected environment'),
- submitLabel: __('Create service account'),
- cancelLabel: __('Cancel'),
- checkboxLabel: __(
- 'I understand the responsibilities involved with managing service account keys',
+ refsLabel: s__('GoogleCloud|Refs'),
+ refsDescription: s__(
+ 'GoogleCloud|Generated service account is linked to the selected branch or tag',
+ ),
+ submitLabel: s__('GoogleCloud|Create service account'),
+ cancelLabel: s__('GoogleCloud|Cancel'),
+ checkboxLabel: s__(
+ 'GoogleCloud|I understand the responsibilities involved with managing service account keys',
),
},
};
@@ -47,18 +50,14 @@ export default {
</gl-form-select>
</gl-form-group>
<gl-form-group
- label-for="environment"
- :label="$options.i18n.environmentLabel"
- :description="$options.i18n.environmentDescription"
+ label-for="ref"
+ :label="$options.i18n.refsLabel"
+ :description="$options.i18n.refsDescription"
>
- <gl-form-select id="environment" name="environment" required>
- <option value="*">{{ __('All') }}</option>
- <option
- v-for="environment in environments"
- :key="environment.name"
- :value="environment.name"
- >
- {{ environment.name }}
+ <gl-form-select id="ref" name="ref" required>
+ <option :value="$options.ALL_REFS">{{ __('All') }}</option>
+ <option v-for="ref in refs" :key="ref" :value="ref">
+ {{ ref }}
</option>
</gl-form-select>
</gl-form-group>
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
index 4db84746482..37b716d7be5 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
@@ -18,16 +18,12 @@ export default {
required: true,
},
},
- data() {
- return {
- tableFields: [
- { key: 'environment', label: __('Environment'), sortable: true },
- { key: 'gcp_project', label: __('Google Cloud Project'), sortable: true },
- { key: 'service_account_exists', label: __('Service Account'), sortable: true },
- { key: 'service_account_key_exists', label: __('Service Account Key'), sortable: true },
- ],
- };
- },
+ tableFields: [
+ { key: 'ref', label: __('Environment'), sortable: true },
+ { key: 'gcp_project', label: __('Google Cloud Project'), sortable: true },
+ { key: 'service_account_exists', label: __('Service Account'), sortable: true },
+ { key: 'service_account_key_exists', label: __('Service Account Key'), sortable: true },
+ ],
i18n: {
createServiceAccount: __('Create service account'),
found: __('✔'),
@@ -62,7 +58,7 @@ export default {
<h2 class="gl-font-size-h2">{{ $options.i18n.serviceAccountsTitle }}</h2>
<p>{{ $options.i18n.serviceAccountsDescription }}</p>
- <gl-table :items="list" :fields="tableFields">
+ <gl-table :items="list" :fields="$options.tableFields">
<template #cell(service_account_exists)="{ value }">
{{ value ? $options.i18n.found : $options.i18n.notFound }}
</template>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index 55987ce64e6..f42152006d2 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -150,7 +150,7 @@ export const trackSaasTrialProject = () => {
});
};
-export const trackSaasTrialProjectImport = () => {
+export const trackProjectImport = () => {
if (!isSupported()) {
return;
}
@@ -159,7 +159,7 @@ export const trackSaasTrialProjectImport = () => {
importButtons.forEach((button) => {
button.addEventListener('click', () => {
const { platform } = button.dataset;
- pushEvent('saasTrialProjectImport', { saasProjectImport: platform });
+ pushEvent('projectImport', { platform });
});
});
};
@@ -231,3 +231,43 @@ export const trackTransaction = (transactionDetails) => {
pushEnhancedEcommerceEvent('EECtransactionSuccess', eventData);
};
+
+export const trackAddToCartUsageTab = () => {
+ if (!isSupported()) {
+ return;
+ }
+
+ const getStartedButton = document.querySelector('.js-buy-additional-minutes');
+ getStartedButton.addEventListener('click', () => {
+ window.dataLayer.push({
+ event: 'EECproductAddToCart',
+ ecommerce: {
+ currencyCode: 'USD',
+ add: {
+ products: [
+ {
+ name: 'CI/CD Minutes',
+ id: '0003',
+ price: '10',
+ brand: 'GitLab',
+ category: 'DevOps',
+ variant: 'add-on',
+ quantity: 1,
+ },
+ ],
+ },
+ },
+ });
+ });
+};
+
+export const trackCombinedGroupProjectForm = () => {
+ if (!isSupported()) {
+ return;
+ }
+
+ const form = document.querySelector('.js-groups-projects-form');
+ form.addEventListener('submit', () => {
+ pushEvent('combinedGroupProjectFormSubmit');
+ });
+};
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
index 7964e762dac..d376c9f76ba 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { queryToObject } from '~/lib/utils/url_utility';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { __ } from '~/locale';
@@ -14,7 +15,7 @@ export default class GpgBadges {
const badges = $('.js-loading-gpg-badge');
- badges.html('<span class="gl-spinner gl-spinner-orange gl-spinner-sm"></span>');
+ badges.html(loadingIconForLegacyJS());
badges.children().attr('aria-label', __('Loading'));
const displayError = () =>
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 3b36c3e6ac5..4ebb49b4756 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -1,9 +1,11 @@
export const MINIMUM_SEARCH_LENGTH = 3;
+export const TYPE_BOARD = 'Board';
export const TYPE_CI_RUNNER = 'Ci::Runner';
export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact';
export const TYPE_DISCUSSION = 'Discussion';
export const TYPE_EPIC = 'Epic';
+export const TYPE_EPIC_BOARD = 'Boards::EpicBoard';
export const TYPE_GROUP = 'Group';
export const TYPE_ISSUE = 'Issue';
export const TYPE_ITERATION = 'Iteration';
diff --git a/app/assets/javascripts/graphql_shared/possibleTypes.json b/app/assets/javascripts/graphql_shared/possibleTypes.json
index 9a24d2a3afc..01116067887 100644
--- a/app/assets/javascripts/graphql_shared/possibleTypes.json
+++ b/app/assets/javascripts/graphql_shared/possibleTypes.json
@@ -1 +1 @@
-{"AlertManagementIntegration":["AlertManagementHttpIntegration","AlertManagementPrometheusIntegration"],"CurrentUserTodos":["BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest"],"DependencyLinkMetadata":["NugetDependencyLinkMetadata"],"DesignFields":["Design","DesignAtVersion"],"Entry":["Blob","Submodule","TreeEntry"],"Eventable":["BoardEpic","Epic"],"Issuable":["Epic","Issue","MergeRequest"],"JobNeedUnion":["CiBuildNeed","CiJob"],"MemberInterface":["GroupMember","ProjectMember"],"NoteableInterface":["AlertManagementAlert","BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest","Snippet","Vulnerability"],"NoteableType":["Design","Issue","MergeRequest"],"OrchestrationPolicy":["ScanExecutionPolicy","ScanResultPolicy"],"PackageFileMetadata":["ConanFileMetadata","HelmFileMetadata"],"PackageMetadata":["ComposerMetadata","ConanMetadata","MavenMetadata","NugetMetadata","PypiMetadata"],"ResolvableInterface":["Discussion","Note"],"Service":["BaseService","JiraService"],"TimeboxReportInterface":["Iteration","Milestone"],"User":["MergeRequestAssignee","MergeRequestReviewer","UserCore"],"VulnerabilityDetail":["VulnerabilityDetailBase","VulnerabilityDetailBoolean","VulnerabilityDetailCode","VulnerabilityDetailCommit","VulnerabilityDetailDiff","VulnerabilityDetailFileLocation","VulnerabilityDetailInt","VulnerabilityDetailList","VulnerabilityDetailMarkdown","VulnerabilityDetailModuleLocation","VulnerabilityDetailTable","VulnerabilityDetailText","VulnerabilityDetailUrl"],"VulnerabilityLocation":["VulnerabilityLocationClusterImageScanning","VulnerabilityLocationContainerScanning","VulnerabilityLocationCoverageFuzzing","VulnerabilityLocationDast","VulnerabilityLocationDependencyScanning","VulnerabilityLocationGeneric","VulnerabilityLocationSast","VulnerabilityLocationSecretDetection"]}
+{"AlertManagementIntegration":["AlertManagementHttpIntegration","AlertManagementPrometheusIntegration"],"CurrentUserTodos":["BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest"],"DependencyLinkMetadata":["NugetDependencyLinkMetadata"],"DesignFields":["Design","DesignAtVersion"],"Entry":["Blob","Submodule","TreeEntry"],"Eventable":["BoardEpic","Epic"],"Issuable":["Epic","Issue","MergeRequest","WorkItem"],"JobNeedUnion":["CiBuildNeed","CiJob"],"MemberInterface":["GroupMember","ProjectMember"],"NoteableInterface":["AlertManagementAlert","BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest","Snippet","Vulnerability"],"NoteableType":["Design","Issue","MergeRequest"],"OrchestrationPolicy":["ScanExecutionPolicy","ScanResultPolicy"],"PackageFileMetadata":["ConanFileMetadata","HelmFileMetadata"],"PackageMetadata":["ComposerMetadata","ConanMetadata","MavenMetadata","NugetMetadata","PypiMetadata"],"ResolvableInterface":["Discussion","Note"],"Service":["BaseService","JiraService"],"TimeboxReportInterface":["Iteration","Milestone"],"Todoable":["AlertManagementAlert","BoardEpic","Commit","Design","Epic","EpicIssue","Issue","MergeRequest"],"User":["MergeRequestAssignee","MergeRequestAuthor","MergeRequestParticipant","MergeRequestReviewer","UserCore"],"VulnerabilityDetail":["VulnerabilityDetailBase","VulnerabilityDetailBoolean","VulnerabilityDetailCode","VulnerabilityDetailCommit","VulnerabilityDetailDiff","VulnerabilityDetailFileLocation","VulnerabilityDetailInt","VulnerabilityDetailList","VulnerabilityDetailMarkdown","VulnerabilityDetailModuleLocation","VulnerabilityDetailTable","VulnerabilityDetailText","VulnerabilityDetailUrl"],"VulnerabilityLocation":["VulnerabilityLocationClusterImageScanning","VulnerabilityLocationContainerScanning","VulnerabilityLocationCoverageFuzzing","VulnerabilityLocationDast","VulnerabilityLocationDependencyScanning","VulnerabilityLocationGeneric","VulnerabilityLocationSast","VulnerabilityLocationSecretDetection"]} \ No newline at end of file
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql
new file mode 100644
index 00000000000..2bd016feb19
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql
@@ -0,0 +1,24 @@
+#import "../fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query projectUsersSearchWithMRPermissions(
+ $search: String!
+ $fullPath: ID!
+ $mergeRequestId: MergeRequestID!
+) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
+ nodes {
+ id
+ mergeRequestInteraction(id: $mergeRequestId) {
+ canMerge
+ }
+ user {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index c24eeed9f03..3620c884c5f 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -68,7 +68,7 @@ export default {
/>
<item-stats-value
v-if="isGroup"
- :title="__('Members')"
+ :title="__('Direct members')"
:value="item.memberCount"
css-class="number-users gl-ml-5"
icon-name="users"
diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
index 9f4f4768247..c0e2c18bece 100644
--- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
@@ -4,20 +4,28 @@ import {
GlDropdownSectionHeader,
GlDropdownDivider,
GlAvatar,
+ GlAlert,
GlLoadingIcon,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
+import { s__ } from '~/locale';
import highlight from '~/lib/utils/highlight';
import { GROUPS_CATEGORY, PROJECTS_CATEGORY, LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
export default {
name: 'HeaderSearchAutocompleteItems',
+ i18n: {
+ autocompleteErrorMessage: s__(
+ 'GlobalSearch|There was an error fetching search autocomplete suggestions.',
+ ),
+ },
components: {
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
GlAvatar,
+ GlAlert,
GlLoadingIcon,
},
directives: {
@@ -31,7 +39,7 @@ export default {
},
},
computed: {
- ...mapState(['search', 'loading']),
+ ...mapState(['search', 'loading', 'autocompleteError']),
...mapGetters(['autocompleteGroupedSearchOptions']),
},
watch: {
@@ -93,5 +101,13 @@ export default {
</div>
</template>
<gl-loading-icon v-else size="lg" class="my-4" />
+ <gl-alert
+ v-if="autocompleteError"
+ class="gl-text-body gl-mt-2"
+ :dismissible="false"
+ variant="danger"
+ >
+ {{ $options.i18n.autocompleteErrorMessage }}
+ </gl-alert>
</div>
</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
index 53e63bc6cca..04deaba7b0f 100644
--- a/app/assets/javascripts/header_search/components/header_search_default_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue
@@ -24,8 +24,8 @@ export default {
...mapGetters(['defaultSearchOptions']),
sectionHeader() {
return (
- this.searchContext.project?.name ||
- this.searchContext.group?.name ||
+ this.searchContext?.project?.name ||
+ this.searchContext?.group?.name ||
this.$options.i18n.allGitLab
);
},
diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js
index d7e21f55ea5..4af8513ecdb 100644
--- a/app/assets/javascripts/header_search/index.js
+++ b/app/assets/javascripts/header_search/index.js
@@ -5,7 +5,7 @@ import createStore from './store';
Vue.use(Translate);
-export const initHeaderSearchApp = () => {
+export const initHeaderSearchApp = (search = '') => {
const el = document.getElementById('js-header-search');
if (!el) {
@@ -18,7 +18,7 @@ export const initHeaderSearchApp = () => {
return new Vue({
el,
- store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
+ store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }),
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
index 0ba956f3ed1..ee4c312fed0 100644
--- a/app/assets/javascripts/header_search/store/actions.js
+++ b/app/assets/javascripts/header_search/store/actions.js
@@ -1,6 +1,4 @@
-import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
import * as types from './mutation_types';
export const fetchAutocompleteOptions = ({ commit, getters }) => {
@@ -10,7 +8,6 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => {
.then(({ data }) => commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data))
.catch(() => {
commit(types.RECEIVE_AUTOCOMPLETE_ERROR);
- createFlash({ message: __('There was an error fetching search autocomplete suggestions') });
});
};
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index a1348a8aa3f..87dec95153f 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -17,9 +17,12 @@ export const searchQuery = (state) => {
{
search: state.search,
nav_source: 'navbar',
- project_id: state.searchContext.project?.id,
- group_id: state.searchContext.group?.id,
+ project_id: state.searchContext?.project?.id,
+ group_id: state.searchContext?.group?.id,
scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
},
isNil,
);
@@ -31,7 +34,7 @@ export const autocompleteQuery = (state) => {
const query = omitBy(
{
term: state.search,
- project_id: state.searchContext.project?.id,
+ project_id: state.searchContext?.project?.id,
project_ref: state.searchContext?.ref,
},
isNil,
@@ -42,16 +45,16 @@ export const autocompleteQuery = (state) => {
export const scopedIssuesPath = (state) => {
return (
- state.searchContext.project_metadata?.issues_path ||
- state.searchContext.group_metadata?.issues_path ||
+ 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.searchContext?.project_metadata?.mr_path ||
+ state.searchContext?.group_metadata?.mr_path ||
state.mrPath
);
};
@@ -96,6 +99,9 @@ export const projectUrl = (state) => {
project_id: state.searchContext?.project?.id,
group_id: state.searchContext?.group?.id,
scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
},
isNil,
);
@@ -110,6 +116,9 @@ export const groupUrl = (state) => {
nav_source: 'navbar',
group_id: state.searchContext?.group?.id,
scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
},
isNil,
);
@@ -123,6 +132,9 @@ export const allUrl = (state) => {
search: state.search,
nav_source: 'navbar',
scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
},
isNil,
);
@@ -133,19 +145,19 @@ export const allUrl = (state) => {
export const scopedSearchOptions = (state, getters) => {
const options = [];
- if (state.searchContext.project) {
+ if (state.searchContext?.project) {
options.push({
html_id: 'scoped-in-project',
- scope: state.searchContext.project.name,
+ scope: state.searchContext.project?.name || '',
description: MSG_IN_PROJECT,
url: getters.projectUrl,
});
}
- if (state.searchContext.group) {
+ if (state.searchContext?.group) {
options.push({
html_id: 'scoped-in-group',
- scope: state.searchContext.group.name,
+ scope: state.searchContext.group?.name || '',
description: MSG_IN_GROUP,
url: getters.groupUrl,
});
diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js
index 06cca4be8a7..b83433c5b49 100644
--- a/app/assets/javascripts/header_search/store/index.js
+++ b/app/assets/javascripts/header_search/store/index.js
@@ -13,11 +13,12 @@ export const getStoreConfig = ({
mrPath,
autocompletePath,
searchContext,
+ search,
}) => ({
actions,
getters,
mutations,
- state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
+ state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js
index 26b4a8854fe..92948bec515 100644
--- a/app/assets/javascripts/header_search/store/mutations.js
+++ b/app/assets/javascripts/header_search/store/mutations.js
@@ -4,19 +4,23 @@ export default {
[types.REQUEST_AUTOCOMPLETE](state) {
state.loading = true;
state.autocompleteOptions = [];
+ state.autocompleteError = false;
},
[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
state.loading = false;
state.autocompleteOptions = data.map((d, i) => {
return { html_id: `autocomplete-${d.category}-${i}`, ...d };
});
+ state.autocompleteError = false;
},
[types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
state.loading = false;
state.autocompleteOptions = [];
+ state.autocompleteError = true;
},
[types.CLEAR_AUTOCOMPLETE](state) {
state.autocompleteOptions = [];
+ state.autocompleteError = false;
},
[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
index 3d4073f0583..bebdbc7b92e 100644
--- a/app/assets/javascripts/header_search/store/state.js
+++ b/app/assets/javascripts/header_search/store/state.js
@@ -1,11 +1,19 @@
-const createState = ({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }) => ({
+const createState = ({
searchPath,
issuesPath,
mrPath,
autocompletePath,
searchContext,
- search: '',
+ search,
+}) => ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search,
autocompleteOptions: [],
+ autocompleteError: false,
loading: false,
});
export default createState;
diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue
index 0803925104d..0921b5a5424 100644
--- a/app/assets/javascripts/ide/components/file_templates/bar.vue
+++ b/app/assets/javascripts/ide/components/file_templates/bar.vue
@@ -1,17 +1,46 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import Dropdown from './dropdown.vue';
+import { __ } from '~/locale';
+
+const barLabel = __('File templates');
+const templateListDropdownLabel = __('Choose a template...');
+const templateTypesDropdownLabel = __('Choose a type...');
+const undoButtonText = __('Undo');
export default {
+ i18n: {
+ barLabel,
+ templateListDropdownLabel,
+ templateTypesDropdownLabel,
+ undoButtonText,
+ },
components: {
- Dropdown,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ data() {
+ return {
+ search: '',
+ };
},
computed: {
...mapGetters(['activeFile']),
...mapGetters('fileTemplates', ['templateTypes']),
- ...mapState('fileTemplates', ['selectedTemplateType', 'updateSuccess']),
+ ...mapState('fileTemplates', [
+ 'selectedTemplateType',
+ 'updateSuccess',
+ 'templates',
+ 'isLoading',
+ ]),
+ filteredTemplateTypes() {
+ return this.templates.filter((t) => {
+ return t.name.toLowerCase().includes(this.search.toLowerCase());
+ });
+ },
showTemplatesDropdown() {
return Object.keys(this.selectedTemplateType).length > 0;
},
@@ -26,6 +55,7 @@ export default {
...mapActions('fileTemplates', [
'setSelectedTemplateType',
'fetchTemplate',
+ 'fetchTemplateTypes',
'undoFileTemplate',
]),
setInitialType() {
@@ -50,27 +80,46 @@ export default {
<template>
<div
- class="d-flex align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1"
+ class="gl-display-flex gl-align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1"
>
- <strong class="gl-mr-3"> {{ __('File templates') }} </strong>
- <dropdown
- :data="templateTypes"
- :label="selectedTemplateType.name || __('Choose a type...')"
- class="mr-2"
- @click="selectTemplateType"
- />
- <dropdown
+ <strong class="gl-mr-3"> {{ $options.i18n.barLabel }} </strong>
+ <gl-dropdown
+ class="gl-mr-6"
+ :text="selectedTemplateType.name || $options.i18n.templateTypesDropdownLabel"
+ >
+ <gl-dropdown-item
+ v-for="template in templateTypes"
+ :key="template.key"
+ @click.prevent="selectTemplateType(template)"
+ >
+ {{ template.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <gl-dropdown
v-if="showTemplatesDropdown"
- :label="__('Choose a template...')"
- :is-async-data="true"
- :searchable="true"
- :title="__('File templates')"
- class="mr-2 qa-file-template-dropdown"
- @click="selectTemplate"
- />
+ class="gl-mr-6 qa-file-template-dropdown"
+ :text="$options.i18n.templateListDropdownLabel"
+ @show="fetchTemplateTypes"
+ >
+ <template #header>
+ <gl-search-box-by-type v-model.trim="search" data-qa-selector="dropdown_filter_input" />
+ </template>
+ <div>
+ <gl-loading-icon v-if="isLoading" />
+ <template v-else>
+ <gl-dropdown-item
+ v-for="template in filteredTemplateTypes"
+ :key="template.key"
+ @click="selectTemplate(template)"
+ >
+ {{ template.name }}
+ </gl-dropdown-item>
+ </template>
+ </div>
+ </gl-dropdown>
<transition name="fade">
<gl-button v-show="updateSuccess" category="secondary" variant="default" @click="undo">
- {{ __('Undo') }}
+ {{ $options.i18n.undoButtonText }}
</gl-button>
</transition>
</div>
diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
index ec61e3374d7..e8b42ac9490 100644
--- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue
+++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
@@ -84,7 +84,7 @@ export default {
v-model="search"
:placeholder="__('Filter...')"
type="search"
- class="dropdown-input-field qa-dropdown-filter-input"
+ class="dropdown-input-field"
/>
<gl-icon name="search" class="dropdown-input-search" />
</div>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index 1c5a00568eb..e3c230f7660 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -6,6 +6,10 @@ import { __, sprintf } from '~/locale';
import { modalTypes } from '../../constants';
import { trimPathComponents, getPathParent } from '../../utils';
+const i18n = {
+ cancelButtonText: __('Cancel'),
+};
+
export default {
components: {
GlModal,
@@ -43,6 +47,18 @@ export default {
return __('Create file');
},
+ actionPrimary() {
+ return {
+ text: this.buttonLabel,
+ attributes: [{ variant: 'confirm' }],
+ };
+ },
+ actionCancel() {
+ return {
+ text: i18n.cancelButtonText,
+ attributes: [{ variant: 'default' }],
+ };
+ },
isCreatingNewFile() {
return this.modalType === modalTypes.blob;
},
@@ -136,11 +152,11 @@ export default {
data-qa-selector="new_file_modal"
data-testid="ide-new-entry"
:title="modalTitle"
- :ok-title="buttonLabel"
- ok-variant="success"
size="lg"
- @ok="submitForm"
- @hide="resetData"
+ :action-primary="actionPrimary"
+ :action-cancel="actionCancel"
+ @primary="submitForm"
+ @cancel="resetData"
>
<div class="form-group row">
<label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 05493db1dff..f14d86114b8 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -147,6 +147,9 @@ export default {
fileType() {
return this.previewMode?.id || '';
},
+ showTabs() {
+ return !this.shouldHideEditor && this.isEditModeActive && this.previewMode;
+ },
},
watch: {
'file.name': {
@@ -194,6 +197,9 @@ export default {
this.refreshEditorDimensions();
}
},
+ showTabs() {
+ this.$nextTick(() => this.refreshEditorDimensions());
+ },
rightPaneIsOpen() {
this.refreshEditorDimensions();
},
@@ -410,7 +416,7 @@ export default {
}
},
refreshEditorDimensions() {
- if (this.showEditor) {
+ if (this.showEditor && this.editor) {
this.editor.updateDimensions();
}
},
@@ -495,7 +501,7 @@ export default {
<template>
<div id="ide" class="blob-viewer-container blob-editor-container">
- <div v-if="!shouldHideEditor && isEditModeActive" class="ide-mode-tabs clearfix">
+ <div v-if="showTabs" class="ide-mode-tabs clearfix">
<ul class="nav-links float-left border-bottom-0">
<li :class="editTabCSS">
<a
@@ -506,7 +512,7 @@ export default {
>{{ __('Edit') }}</a
>
</li>
- <li v-if="previewMode" :class="previewTabCSS">
+ <li :class="previewTabCSS">
<a
href="javascript:void(0);"
role="button"
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 b9f0b5012ac..bd0f4cd5dd7 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
@@ -137,7 +137,7 @@ export default {
<gl-form-input
data-qa-selector="githubish_import_filter_field"
name="filter"
- :placeholder="__('Filter your repositories by name')"
+ :placeholder="__('Filter by name')"
autofocus
size="lg"
@keyup.enter="setFilter($event.target.value)"
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 7a904bdb6ad..324797ad645 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlLink,
GlLoadingIcon,
GlTable,
GlAvatarsInline,
@@ -24,9 +25,11 @@ import {
} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import {
I18N,
INCIDENT_STATUS_TABS,
+ ESCALATION_STATUSES,
TH_CREATED_AT_TEST_ID,
TH_INCIDENT_SLA_TEST_ID,
TH_SEVERITY_TEST_ID,
@@ -38,7 +41,7 @@ import {
import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import getIncidents from '../graphql/queries/get_incidents.query.graphql';
-const MAX_VISIBLE_ASSIGNEES = 4;
+const MAX_VISIBLE_ASSIGNEES = 3;
export default {
trackIncidentCreateNewOptions,
@@ -49,7 +52,7 @@ export default {
{
key: 'severity',
label: s__('IncidentManagement|Severity'),
- thClass: `${thClass} w-15p`,
+ thClass: `${thClass} gl-w-15p`,
tdClass: `${tdClass} sortable-cell`,
actualSortKey: 'SEVERITY',
sortable: true,
@@ -62,6 +65,12 @@ export default {
tdClass,
},
{
+ key: 'escalationStatus',
+ label: s__('IncidentManagement|Status'),
+ thClass: `${thClass} gl-w-eighth gl-pointer-events-none`,
+ tdClass,
+ },
+ {
key: 'createdAt',
label: s__('IncidentManagement|Date created'),
thClass: `${thClass} gl-w-eighth`,
@@ -73,7 +82,7 @@ export default {
{
key: 'incidentSla',
label: s__('IncidentManagement|Time to SLA'),
- thClass: `gl-text-right gl-w-eighth`,
+ thClass: `gl-text-right gl-w-10p`,
tdClass: `${tdClass} gl-text-right`,
thAttr: TH_INCIDENT_SLA_TEST_ID,
actualSortKey: 'SLA_DUE_AT',
@@ -83,13 +92,13 @@ export default {
{
key: 'assignees',
label: s__('IncidentManagement|Assignees'),
- thClass: 'gl-pointer-events-none w-15p',
+ thClass: 'gl-pointer-events-none gl-w-15',
tdClass,
},
{
key: 'published',
label: s__('IncidentManagement|Published'),
- thClass: `${thClass} w-15p`,
+ thClass: `${thClass} gl-w-15`,
tdClass: `${tdClass} sortable-cell`,
actualSortKey: 'PUBLISHED',
sortable: true,
@@ -98,6 +107,7 @@ export default {
],
MAX_VISIBLE_ASSIGNEES,
components: {
+ GlLink,
GlLoadingIcon,
GlTable,
GlAvatarsInline,
@@ -112,6 +122,7 @@ export default {
GlEmptyState,
SeverityToken,
PaginatedTableWithSearchAndTabs,
+ TooltipOnTruncate,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -129,6 +140,7 @@ export default {
'assigneeUsernameQuery',
'slaFeatureAvailable',
'canCreateIncident',
+ 'incidentEscalationsAvailable',
],
apollo: {
incidents: {
@@ -222,6 +234,7 @@ export default {
const isHidden = {
published: !this.publishedAvailable,
incidentSla: !this.slaFeatureAvailable,
+ escalationStatus: !this.incidentEscalationsAvailable,
};
return this.$options.fields.filter(({ key }) => !isHidden[key]);
@@ -260,7 +273,7 @@ export default {
return Boolean(assignees.nodes?.length);
},
navigateToIncidentDetails({ iid }) {
- return visitUrl(joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid));
+ return visitUrl(this.showIncidentLink({ iid }));
},
navigateToCreateNewIncident() {
const { category, action } = this.$options.trackIncidentCreateNewOptions;
@@ -283,6 +296,12 @@ export default {
getSeverity(severity) {
return INCIDENT_SEVERITY[severity];
},
+ getEscalationStatus(escalationStatus) {
+ return ESCALATION_STATUSES[escalationStatus] || this.$options.i18n.noEscalationStatus;
+ },
+ showIncidentLink({ iid }) {
+ return joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid);
+ },
pageChanged(pagination) {
this.pagination = pagination;
},
@@ -370,7 +389,14 @@ export default {
<template #cell(title)="{ item }">
<div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }">
- <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div>
+ <gl-link
+ v-gl-tooltip
+ :title="item.title"
+ data-testid="incident-link"
+ :href="showIncidentLink(item)"
+ >
+ {{ item.title }}
+ </gl-link>
<gl-icon
v-if="item.state === 'closed'"
name="issue-close"
@@ -381,8 +407,21 @@ export default {
</div>
</template>
+ <template v-if="incidentEscalationsAvailable" #cell(escalationStatus)="{ item }">
+ <tooltip-on-truncate
+ :title="getEscalationStatus(item.escalationStatus)"
+ data-testid="incident-escalation-status"
+ class="gl-display-block gl-text-truncate"
+ >
+ {{ getEscalationStatus(item.escalationStatus) }}
+ </tooltip-on-truncate>
+ </template>
+
<template #cell(createdAt)="{ item }">
- <time-ago-tooltip :time="item.createdAt" />
+ <time-ago-tooltip
+ :time="item.createdAt"
+ class="gl-display-block gl-max-w-full gl-text-truncate"
+ />
</template>
<template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }">
@@ -392,6 +431,7 @@ export default {
:project-path="projectPath"
:sla-due-at="item.slaDueAt"
data-testid="incident-sla"
+ class="gl-display-block gl-max-w-full gl-text-truncate"
/>
</template>
@@ -432,6 +472,7 @@ export default {
:un-published="$options.i18n.unPublished"
/>
</template>
+
<template #table-busy>
<gl-loading-icon size="lg" color="dark" class="mt-3" />
</template>
diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js
index 23909ae3b6c..21cdbef05a1 100644
--- a/app/assets/javascripts/incidents/constants.js
+++ b/app/assets/javascripts/incidents/constants.js
@@ -7,6 +7,7 @@ export const I18N = {
unassigned: s__('IncidentManagement|Unassigned'),
createIncidentBtnLabel: s__('IncidentManagement|Create incident'),
unPublished: s__('IncidentManagement|Unpublished'),
+ noEscalationStatus: s__('IncidentManagement|None'),
emptyState: {
title: s__('IncidentManagement|Display your incidents in a dedicated view'),
emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'),
@@ -37,6 +38,12 @@ export const INCIDENT_STATUS_TABS = [
},
];
+export const ESCALATION_STATUSES = {
+ TRIGGERED: s__('AlertManagement|Triggered'),
+ ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
+ RESOLVED: s__('AlertManagement|Resolved'),
+};
+
export const DEFAULT_PAGE_SIZE = 20;
export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
diff --git a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql
index faa68d37088..b72941966c6 100644
--- a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql
+++ b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql
@@ -1,4 +1,5 @@
# eslint-disable-next-line @graphql-eslint/require-id-when-available
fragment IncidentFields on Issue {
severity
+ escalationStatus
}
diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js
index 1d40f1093a4..c0f16a43d5c 100644
--- a/app/assets/javascripts/incidents/list.js
+++ b/app/assets/javascripts/incidents/list.js
@@ -46,6 +46,7 @@ export default () => {
assigneeUsernameQuery,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
canCreateIncident: parseBoolean(canCreateIncident),
+ incidentEscalationsAvailable: parseBoolean(gon?.features?.incidentEscalations),
},
apolloProvider,
render(createElement) {
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index 004601bc0a3..c5ed5bb08a9 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -1,6 +1,7 @@
import { s__, __ } from '~/locale';
export const integrationLevels = {
+ PROJECT: 'project',
GROUP: 'group',
INSTANCE: 'instance',
};
@@ -24,3 +25,15 @@ export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection s
export const settingsTabTitle = __('Settings');
export const overridesTabTitle = s__('Integrations|Projects using custom settings');
+
+export const integrationFormSections = {
+ CONNECTION: 'connection',
+ JIRA_TRIGGER: 'jira_trigger',
+ JIRA_ISSUES: 'jira_issues',
+};
+
+export const integrationFormSectionComponents = {
+ [integrationFormSections.CONNECTION]: 'IntegrationSectionConnection',
+ [integrationFormSections.JIRA_TRIGGER]: 'IntegrationSectionJiraTrigger',
+ [integrationFormSections.JIRA_ISSUES]: 'IntegrationSectionJiraIssues',
+};
diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
index 5ddf3aeb639..a4415a5a2b3 100644
--- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
@@ -1,6 +1,6 @@
<script>
import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
+import { mapGetters, mapState } from 'vuex';
export default {
name: 'ActiveCheckbox',
@@ -15,6 +15,10 @@ export default {
},
computed: {
...mapGetters(['isInheriting', 'propsSource']),
+ ...mapState(['customState']),
+ disabled() {
+ return this.isInheriting || this.customState.activateDisabled;
+ },
},
mounted() {
this.activated = this.propsSource.initialActivated;
@@ -34,7 +38,7 @@ export default {
<gl-form-checkbox
v-model="activated"
class="gl-display-block"
- :disabled="isInheriting"
+ :disabled="disabled"
@change="onChange"
>
{{ __('Active') }}
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 007a384f41e..6e89872ff68 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -3,12 +3,14 @@ import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml, GlForm } f
import axios from 'axios';
import * as Sentry from '@sentry/browser';
import { mapState, mapActions, mapGetters } from 'vuex';
+
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
I18N_SUCCESSFUL_CONNECTION_MESSAGE,
integrationLevels,
+ integrationFormSectionComponents,
} from '~/integrations/constants';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import csrf from '~/lib/utils/csrf';
@@ -33,6 +35,18 @@ export default {
DynamicField,
ConfirmationModal,
ResetConfirmationModal,
+ IntegrationSectionConnection: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionConnection' */ '~/integrations/edit/components/sections/connection.vue'
+ ),
+ IntegrationSectionJiraIssues: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionJiraIssues' */ '~/integrations/edit/components/sections/jira_issues.vue'
+ ),
+ IntegrationSectionJiraTrigger: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionJiraTrigger' */ '~/integrations/edit/components/sections/jira_trigger.vue'
+ ),
GlButton,
GlForm,
},
@@ -41,10 +55,13 @@ export default {
SafeHtml,
},
mixins: [glFeatureFlagsMixin()],
- props: {
+ provide() {
+ return {
+ hasSections: this.hasSections,
+ };
+ },
+ inject: {
helpHtml: {
- type: String,
- required: false,
default: '',
},
},
@@ -81,28 +98,42 @@ export default {
disableButtons() {
return Boolean(this.isSaving || this.isResetting || this.isTesting);
},
- form() {
- return this.$refs.integrationForm.$el;
+ sectionsEnabled() {
+ return this.glFeatures.integrationFormSections;
+ },
+ hasSections() {
+ return this.sectionsEnabled && this.customState.sections.length !== 0;
+ },
+ fieldsWithoutSection() {
+ return this.sectionsEnabled
+ ? this.propsSource.fields.filter((field) => !field.section)
+ : this.propsSource.fields;
},
},
methods: {
...mapActions(['setOverride', 'requestJiraIssueTypes']),
+ fieldsForSection(section) {
+ return this.propsSource.fields.filter((field) => field.section === section.type);
+ },
+ form() {
+ return this.$refs.integrationForm.$el;
+ },
setIsValidated() {
this.isValidated = true;
},
onSaveClick() {
this.isSaving = true;
- if (this.integrationActive && !this.form.checkValidity()) {
+ if (this.integrationActive && !this.form().checkValidity()) {
this.isSaving = false;
this.setIsValidated();
return;
}
- this.form.submit();
+ this.form().submit();
},
onTestClick() {
- if (!this.form.checkValidity()) {
+ if (!this.form().checkValidity()) {
this.setIsValidated();
return;
}
@@ -147,7 +178,7 @@ export default {
this.requestJiraIssueTypes(this.getFormData());
},
getFormData() {
- return new FormData(this.form);
+ return new FormData(this.form());
},
onToggleIntegrationState(integrationActive) {
this.integrationActive = integrationActive;
@@ -159,6 +190,7 @@ export default {
FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes
},
csrf,
+ integrationFormSectionComponents,
};
</script>
@@ -187,46 +219,75 @@ export default {
@change="setOverride"
/>
+ <template v-if="hasSections">
+ <div
+ v-for="(section, index) in customState.sections"
+ :key="section.type"
+ :class="{ 'gl-border-b gl-pb-3 gl-mb-6': index !== customState.sections.length - 1 }"
+ data-testid="integration-section"
+ >
+ <div class="row">
+ <div class="col-lg-4">
+ <h4 class="gl-mt-0">{{ section.title }}</h4>
+ <p v-safe-html="section.description"></p>
+ </div>
+
+ <div class="col-lg-8">
+ <component
+ :is="$options.integrationFormSectionComponents[section.type]"
+ :fields="fieldsForSection(section)"
+ :is-validated="isValidated"
+ @toggle-integration-active="onToggleIntegrationState"
+ @request-jira-issue-types="onRequestJiraIssueTypes"
+ />
+ </div>
+ </div>
+ </div>
+ </template>
+
<div class="row">
<div class="col-lg-4"></div>
<div class="col-lg-8">
<!-- helpHtml is trusted input -->
- <div v-if="helpHtml" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div>
+ <div v-if="helpHtml && !hasSections" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div>
<active-checkbox
- v-if="propsSource.showActive"
+ v-if="propsSource.showActive && !hasSections"
:key="`${currentKey}-active-checkbox`"
@toggle-integration-active="onToggleIntegrationState"
/>
<jira-trigger-fields
- v-if="isJira"
+ v-if="isJira && !hasSections"
:key="`${currentKey}-jira-trigger-fields`"
v-bind="propsSource.triggerFieldsProps"
:is-validated="isValidated"
/>
<trigger-fields
- v-else-if="propsSource.triggerEvents.length"
+ v-else-if="propsSource.triggerEvents.length && !hasSections"
:key="`${currentKey}-trigger-fields`"
:events="propsSource.triggerEvents"
:type="propsSource.type"
/>
<dynamic-field
- v-for="field in propsSource.fields"
+ v-for="field in fieldsWithoutSection"
:key="`${currentKey}-${field.name}`"
v-bind="field"
:is-validated="isValidated"
/>
<jira-issues-fields
- v-if="isJira && !isInstanceOrGroupLevel"
+ v-if="isJira && !isInstanceOrGroupLevel && !hasSections"
:key="`${currentKey}-jira-issues-fields`"
v-bind="propsSource.jiraIssuesProps"
:is-validated="isValidated"
@request-jira-issue-types="onRequestJiraIssueTypes"
/>
+ </div>
+ </div>
+ <div v-if="isEditable" class="row">
+ <div :class="hasSections ? 'col' : 'col-lg-8 offset-lg-4'">
<div
- v-if="isEditable"
class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"
>
<div>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
index 7f2f7620a86..7cf8e11f162 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -16,6 +16,11 @@ export default {
JiraIssueCreationVulnerabilities: () =>
import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'),
},
+ inject: {
+ hasSections: {
+ default: false,
+ },
+ },
props: {
showJiraIssuesIntegration: {
type: Boolean,
@@ -83,17 +88,17 @@ export default {
i18n: {
sectionTitle: s__('JiraService|View Jira issues in GitLab'),
sectionDescription: s__(
- 'JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only.',
+ 'JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues.',
),
enableCheckboxLabel: s__('JiraService|Enable Jira issues'),
enableCheckboxHelp: s__(
- 'JiraService|Warning: All GitLab users that have access to this GitLab project are able to view all issues from the Jira project specified below.',
+ 'JiraService|Warning: All GitLab users with access to this GitLab project can view all issues from the Jira project you select.',
),
projectKeyLabel: s__('JiraService|Jira project key'),
projectKeyPlaceholder: s__('JiraService|For example, AB'),
requiredFieldFeedback: __('This field is required.'),
issueTrackerConflictWarning: s__(
- 'JiraService|Displaying Jira issues while leaving the GitLab issue functionality enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.',
+ 'JiraService|Displaying Jira issues while leaving GitLab issues also enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.',
),
},
};
@@ -101,9 +106,12 @@ export default {
<template>
<div>
- <gl-form-group :label="$options.i18n.sectionTitle" label-for="jira-issue-settings">
+ <gl-form-group
+ :label="hasSections ? null : $options.i18n.sectionTitle"
+ label-for="jira-issue-settings"
+ >
<div id="jira-issue-settings">
- <p>
+ <p v-if="!hasSections">
{{ $options.i18n.sectionDescription }}
</p>
<template v-if="showJiraIssuesIntegration">
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 df5946b814a..3c06660e7c5 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -62,6 +62,11 @@ export default {
GlLink,
GlSprintf,
},
+ inject: {
+ hasSections: {
+ default: false,
+ },
+ },
props: {
initialTriggerCommit: {
type: Boolean,
@@ -134,12 +139,14 @@ export default {
<template>
<div>
<gl-form-group
- :label="__('Trigger')"
+ :label="hasSections ? null : __('Trigger')"
label-for="service[trigger]"
:description="
- s__(
- 'Integrations|When you mention a Jira issue in a commit or merge request, GitLab creates a remote link and comment (if enabled).',
- )
+ hasSections
+ ? null
+ : s__(
+ 'JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.',
+ )
"
>
<input name="service[commit_events]" type="hidden" :value="triggerCommit || false" />
diff --git a/app/assets/javascripts/integrations/edit/components/sections/connection.vue b/app/assets/javascripts/integrations/edit/components/sections/connection.vue
new file mode 100644
index 00000000000..364e9324e43
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/connection.vue
@@ -0,0 +1,45 @@
+<script>
+import { mapGetters } from 'vuex';
+
+import ActiveCheckbox from '../active_checkbox.vue';
+import DynamicField from '../dynamic_field.vue';
+
+export default {
+ name: 'IntegrationSectionConnection',
+ components: {
+ ActiveCheckbox,
+ DynamicField,
+ },
+ props: {
+ fields: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapGetters(['currentKey', 'propsSource']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <active-checkbox
+ v-if="propsSource.showActive"
+ :key="`${currentKey}-active-checkbox`"
+ @toggle-integration-active="$emit('toggle-integration-active', $event)"
+ />
+ <dynamic-field
+ v-for="field in fields"
+ :key="`${currentKey}-${field.name}`"
+ v-bind="field"
+ :is-validated="isValidated"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue
new file mode 100644
index 00000000000..75202209d38
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue
@@ -0,0 +1,33 @@
+<script>
+import { mapGetters } from 'vuex';
+
+import JiraIssuesFields from '../jira_issues_fields.vue';
+
+export default {
+ name: 'IntegrationSectionJiraIssues',
+ components: {
+ JiraIssuesFields,
+ },
+ props: {
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapGetters(['currentKey', 'propsSource']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <jira-issues-fields
+ :key="`${currentKey}-jira-issues-fields`"
+ v-bind="propsSource.jiraIssuesProps"
+ :is-validated="isValidated"
+ @request-jira-issue-types="$emit('request-jira-issue-types')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue
new file mode 100644
index 00000000000..f36d3b1fbda
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue
@@ -0,0 +1,32 @@
+<script>
+import { mapGetters } from 'vuex';
+
+import JiraTriggerFields from '../jira_trigger_fields.vue';
+
+export default {
+ name: 'IntegrationSectionJiraTrigger',
+ components: {
+ JiraTriggerFields,
+ },
+ props: {
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapGetters(['currentKey', 'propsSource']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <jira-trigger-fields
+ :key="`${currentKey}-jira-trigger-fields`"
+ v-bind="propsSource.triggerFieldsProps"
+ :is-validated="isValidated"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
index 433fe21ad76..92042a5c981 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -1,6 +1,5 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
-import { startCase } from 'lodash';
import { mapGetters } from 'vuex';
import { __ } from '~/locale';
@@ -45,7 +44,6 @@ export default {
fieldName(name) {
return `service[${name}]`;
},
- startCase,
},
};
</script>
@@ -58,10 +56,10 @@ export default {
data-testid="trigger-fields-group"
>
<div id="trigger-fields" class="gl-pt-3">
- <gl-form-group v-for="event in events" :key="event.title" :description="event.description">
+ <gl-form-group v-for="event in events" :key="event.name" :description="event.description">
<input :name="checkboxName(event.name)" type="hidden" :value="event.value || false" />
<gl-form-checkbox v-model="event.value" :disabled="isInheriting">
- {{ startCase(event.title) }}
+ {{ event.title }}
</gl-form-checkbox>
<gl-form-input
v-if="event.field"
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index fbda8c1e3d0..3e58dd0be99 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -22,6 +22,7 @@ function parseDatasetToProps(data) {
editProjectPath,
learnMorePath,
triggerEvents,
+ sections,
fields,
inheritFromId,
integrationLevel,
@@ -38,6 +39,7 @@ function parseDatasetToProps(data) {
const {
showActive,
activated,
+ activateDisabled,
editable,
canTest,
commitEvents,
@@ -53,6 +55,7 @@ function parseDatasetToProps(data) {
return {
initialActivated: activated,
showActive,
+ activateDisabled,
type,
cancelPath,
editable,
@@ -81,6 +84,7 @@ function parseDatasetToProps(data) {
},
learnMorePath,
triggerEvents: JSON.parse(triggerEvents),
+ sections: JSON.parse(sections, { deep: true }),
fields: convertObjectPropsToCamelCase(JSON.parse(fields), { deep: true }),
inheritFromId: parseInt(inheritFromId, 10),
integrationLevel,
@@ -114,13 +118,13 @@ export default function initIntegrationSettingsForm() {
return new Vue({
el: customSettingsEl,
+ name: 'IntegrationEditRoot',
store: createStore(initialState),
+ provide: {
+ helpHtml,
+ },
render(createElement) {
- return createElement(IntegrationForm, {
- props: {
- helpHtml,
- },
- });
+ return createElement(IntegrationForm);
},
});
}
diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js
index b79132128cc..b0adc444395 100644
--- a/app/assets/javascripts/integrations/edit/store/getters.js
+++ b/app/assets/javascripts/integrations/edit/store/getters.js
@@ -1,5 +1,10 @@
+import { integrationLevels } from '~/integrations/constants';
+
export const isInheriting = (state) => (state.defaultState === null ? false : !state.override);
+export const isProjectLevel = (state) =>
+ state.customState.integrationLevel === integrationLevels.PROJECT;
+
export const propsSource = (state, getters) =>
getters.isInheriting ? state.defaultState : state.customState;
diff --git a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
index c08a4d75c59..424a9d3fabd 100644
--- a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
@@ -28,7 +28,12 @@ export default {
</script>
<template>
- <gl-button :class="classes" data-qa-selector="invite_a_group_button" @click="openModal">
+ <gl-button
+ :class="classes"
+ data-qa-selector="invite_a_group_button"
+ data-test-id="invite-group-button"
+ @click="openModal"
+ >
{{ displayText }}
</gl-button>
</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
index 6598000c464..f266d978ffa 100644
--- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
@@ -4,6 +4,7 @@ import Api from '~/api';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants';
import eventHub from '../event_hub';
+import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import GroupSelect from './group_select.vue';
import InviteModalBase from './invite_modal_base.vue';
@@ -55,6 +56,8 @@ export default {
},
data() {
return {
+ invalidFeedbackMessage: '',
+ isLoading: false,
modalId: uniqueId('invite-groups-modal-'),
groupToBeSharedWith: {},
};
@@ -83,13 +86,19 @@ export default {
});
},
methods: {
+ showInvalidFeedbackMessage(response) {
+ this.invalidFeedbackMessage = getInvalidFeedbackMessage(response);
+ },
openModal() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
- sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
+ sendInvite({ accessLevel, expiresAt }) {
+ this.invalidFeedbackMessage = '';
+ this.isLoading = true;
+
const apiShareWithGroup = this.isProject
? Api.projectShareWithGroup.bind(Api)
: Api.groupShareWithGroup.bind(Api);
@@ -101,18 +110,27 @@ export default {
expires_at: expiresAt,
})
.then(() => {
- onSuccess();
this.showSuccessMessage();
})
- .catch(onError);
+ .catch((e) => {
+ this.showInvalidFeedbackMessage(e);
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
},
resetFields() {
+ this.invalidFeedbackMessage = '';
+ this.isLoading = false;
this.groupToBeSharedWith = {};
},
showSuccessMessage() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
this.closeModal();
},
+ clearValidation() {
+ this.invalidFeedbackMessage = '';
+ },
},
labels: GROUP_MODAL_LABELS,
};
@@ -129,10 +147,12 @@ export default {
:label-intro-text="labelIntroText"
:label-search-field="$options.labels.searchField"
:submit-disabled="inviteDisabled"
+ :invalid-feedback-message="invalidFeedbackMessage"
+ :is-loading="isLoading"
@reset="resetFields"
@submit="sendInvite"
>
- <template #select="{ clearValidation }">
+ <template #select>
<group-select
v-model="groupToBeSharedWith"
:access-levels="accessLevels"
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index 6c0fc5caf26..be48a58d838 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -21,6 +21,7 @@ import {
} from '../constants';
import eventHub from '../event_hub';
import { responseMessageFromSuccess } from '../utils/response_message_parser';
+import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import ModalConfetti from './confetti.vue';
import MembersTokenSelect from './members_token_select.vue';
@@ -84,6 +85,8 @@ export default {
},
data() {
return {
+ invalidFeedbackMessage: '',
+ isLoading: false,
modalId: uniqueId('invite-members-modal-'),
newUsersToInvite: [],
selectedTasksToBeDone: [],
@@ -152,6 +155,9 @@ export default {
}
},
methods: {
+ showInvalidFeedbackMessage(response) {
+ this.invalidFeedbackMessage = getInvalidFeedbackMessage(response);
+ },
partitionNewUsersToInvite() {
const [usersToInviteByEmail, usersToAddById] = partition(
this.newUsersToInvite,
@@ -176,7 +182,10 @@ export default {
const tracking = new ExperimentTracking(experimentName);
tracking.event(eventName);
},
- sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
+ sendInvite({ accessLevel, expiresAt }) {
+ this.isLoading = true;
+ this.invalidFeedbackMessage = '';
+
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
const baseData = {
@@ -220,19 +229,17 @@ export default {
const message = responseMessageFromSuccess(responses);
if (message) {
- onError({
- response: {
- data: {
- message,
- },
- },
+ this.showInvalidFeedbackMessage({
+ response: { data: { message } },
});
} else {
- onSuccess();
this.showSuccessMessage();
}
})
- .catch(onError);
+ .catch((e) => this.showInvalidFeedbackMessage(e))
+ .finally(() => {
+ this.isLoading = false;
+ });
},
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
@@ -241,6 +248,8 @@ export default {
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
resetFields() {
+ this.isLoading = false;
+ this.invalidFeedbackMessage = '';
this.newUsersToInvite = [];
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
@@ -260,6 +269,9 @@ export default {
onAccessLevelUpdate(val) {
this.selectedAccessLevel = val;
},
+ clearValidation() {
+ this.invalidFeedbackMessage = '';
+ },
},
labels: MEMBER_MODAL_LABELS,
};
@@ -276,6 +288,8 @@ export default {
:label-search-field="$options.labels.searchField"
:form-group-description="$options.labels.placeHolder"
:submit-disabled="inviteDisabled"
+ :invalid-feedback-message="invalidFeedbackMessage"
+ :is-loading="isLoading"
@reset="resetFields"
@submit="sendInvite"
@access-level="onAccessLevelUpdate"
@@ -288,7 +302,7 @@ export default {
<span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span>
<modal-confetti v-if="isCelebration" />
</template>
- <template #select="{ clearValidation, validationState, labelId }">
+ <template #select="{ validationState, labelId }">
<members-token-select
v-model="newUsersToInvite"
class="gl-mb-2"
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index fc00f5b9343..bafbe94b8bd 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -10,19 +10,27 @@ import {
GlButton,
GlFormInput,
} from '@gitlab/ui';
-import { unescape } from 'lodash';
-import { sanitize } from '~/lib/dompurify';
import { sprintf } from '~/locale';
+import ContentTransition from '~/vue_shared/components/content_transition.vue';
import {
ACCESS_LEVEL,
ACCESS_EXPIRE_DATE,
- INVALID_FEEDBACK_MESSAGE_DEFAULT,
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
HEADER_CLOSE_LABEL,
} from '../constants';
-import { responseMessageFromError } from '../utils/response_message_parser';
+
+const DEFAULT_SLOT = 'default';
+const DEFAULT_SLOTS = [
+ {
+ key: DEFAULT_SLOT,
+ attributes: {
+ class: 'invite-modal-content',
+ 'data-testid': 'invite-modal-initial-content',
+ },
+ },
+];
export default {
components: {
@@ -35,6 +43,7 @@ export default {
GlSprintf,
GlButton,
GlFormInput,
+ ContentTransition,
},
inheritAttrs: false,
props: {
@@ -80,14 +89,37 @@ export default {
required: false,
default: false,
},
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ invalidFeedbackMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ submitButtonText: {
+ type: String,
+ required: false,
+ default: INVITE_BUTTON_TEXT,
+ },
+ currentSlot: {
+ type: String,
+ required: false,
+ default: DEFAULT_SLOT,
+ },
+ extraSlots: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
// Be sure to check out reset!
return {
- invalidFeedbackMessage: '',
selectedAccessLevel: this.defaultAccessLevel,
selectedDate: undefined,
- isLoading: false,
minDate: new Date(),
};
},
@@ -106,6 +138,9 @@ export default {
(key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
);
},
+ contentSlots() {
+ return [...DEFAULT_SLOTS, ...(this.extraSlots || [])];
+ },
},
watch: {
selectedAccessLevel: {
@@ -116,16 +151,9 @@ export default {
},
},
methods: {
- showInvalidFeedbackMessage(response) {
- const message = this.unescapeMsg(responseMessageFromError(response));
-
- this.invalidFeedbackMessage = message || INVALID_FEEDBACK_MESSAGE_DEFAULT;
- },
reset() {
// This component isn't necessarily disposed,
// so we might need to reset it's state.
- this.isLoading = false;
- this.invalidFeedbackMessage = '';
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
@@ -135,33 +163,15 @@ export default {
this.reset();
this.$refs.modal.hide();
},
- clearValidation() {
- this.invalidFeedbackMessage = '';
- },
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
submit() {
- this.isLoading = true;
- this.invalidFeedbackMessage = '';
-
this.$emit('submit', {
- onSuccess: () => {
- this.isLoading = false;
- },
- onError: (...args) => {
- this.isLoading = false;
- this.showInvalidFeedbackMessage(...args);
- },
- data: {
- accessLevel: this.selectedAccessLevel,
- expiresAt: this.selectedDate,
- },
+ accessLevel: this.selectedAccessLevel,
+ expiresAt: this.selectedDate,
});
},
- unescapeMsg(message) {
- return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
- },
},
HEADER_CLOSE_LABEL,
ACCESS_EXPIRE_DATE,
@@ -169,6 +179,7 @@ export default {
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
+ DEFAULT_SLOT,
};
</script>
@@ -185,91 +196,105 @@ export default {
@close="reset"
@hide="reset"
>
- <div class="gl-display-flex" data-testid="modal-base-intro-text">
- <slot name="intro-text-before"></slot>
- <p>
- <gl-sprintf :message="introText">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <slot name="intro-text-after"></slot>
- </div>
-
- <gl-form-group
- :invalid-feedback="invalidFeedbackMessage"
- :state="validationState"
- :description="formGroupDescription"
- data-testid="members-form-group"
+ <content-transition
+ class="gl-display-grid"
+ transition-name="invite-modal-transition"
+ :slots="contentSlots"
+ :current-slot="currentSlot"
>
- <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label>
- <slot
- name="select"
- v-bind="{ clearValidation, validationState, labelId: selectLabelId }"
- ></slot>
- </gl-form-group>
+ <template #[$options.DEFAULT_SLOT]>
+ <div class="gl-display-flex" data-testid="modal-base-intro-text">
+ <slot name="intro-text-before"></slot>
+ <p>
+ <gl-sprintf :message="introText">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <slot name="intro-text-after"></slot>
+ </div>
- <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label>
- <div class="gl-mt-2 gl-w-half gl-xs-w-full">
- <gl-dropdown
- class="gl-shadow-none gl-w-full"
- data-qa-selector="access_level_dropdown"
- v-bind="$attrs"
- :text="selectedRoleName"
- >
- <template v-for="(key, item) in accessLevels">
- <gl-dropdown-item
- :key="key"
- active-class="is-active"
- is-check-item
- :is-checked="key === selectedAccessLevel"
- @click="changeSelectedItem(key)"
- >
- <div>{{ item }}</div>
- </gl-dropdown-item>
- </template>
- </gl-dropdown>
- </div>
+ <gl-form-group
+ :invalid-feedback="invalidFeedbackMessage"
+ :state="validationState"
+ :description="formGroupDescription"
+ data-testid="members-form-group"
+ >
+ <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label>
+ <slot name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot>
+ </gl-form-group>
- <div class="gl-mt-2 gl-w-half gl-xs-w-full">
- <gl-sprintf :message="$options.READ_MORE_TEXT">
- <template #link="{ content }">
- <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
+ <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <gl-dropdown
+ class="gl-shadow-none gl-w-full"
+ data-qa-selector="access_level_dropdown"
+ v-bind="$attrs"
+ :text="selectedRoleName"
+ >
+ <template v-for="(key, item) in accessLevels">
+ <gl-dropdown-item
+ :key="key"
+ active-class="is-active"
+ is-check-item
+ :is-checked="key === selectedAccessLevel"
+ @click="changeSelectedItem(key)"
+ >
+ <div>{{ item }}</div>
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </div>
- <label class="gl-mt-5 gl-display-block" for="expires_at">{{
- $options.ACCESS_EXPIRE_DATE
- }}</label>
- <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
- <gl-datepicker
- v-model="selectedDate"
- class="gl-display-inline!"
- :min-date="minDate"
- :target="null"
- >
- <template #default="{ formattedDate }">
- <gl-form-input class="gl-w-full" :value="formattedDate" :placeholder="__(`YYYY-MM-DD`)" />
- </template>
- </gl-datepicker>
- </div>
- <slot name="form-after"></slot>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <gl-sprintf :message="$options.READ_MORE_TEXT">
+ <template #link="{ content }">
+ <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ <label class="gl-mt-5 gl-display-block" for="expires_at">{{
+ $options.ACCESS_EXPIRE_DATE
+ }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
+ <gl-datepicker
+ v-model="selectedDate"
+ class="gl-display-inline!"
+ :min-date="minDate"
+ :target="null"
+ >
+ <template #default="{ formattedDate }">
+ <gl-form-input
+ class="gl-w-full"
+ :value="formattedDate"
+ :placeholder="__(`YYYY-MM-DD`)"
+ />
+ </template>
+ </gl-datepicker>
+ </div>
+ <slot name="form-after"></slot>
+ </template>
+ <template v-for="{ key } in extraSlots" #[key]>
+ <slot :name="key"></slot>
+ </template>
+ </content-transition>
<template #modal-footer>
- <gl-button data-testid="cancel-button" @click="closeModal">
- {{ $options.CANCEL_BUTTON_TEXT }}
- </gl-button>
+ <slot name="cancel-button">
+ <gl-button data-testid="cancel-button" @click="closeModal">
+ {{ $options.CANCEL_BUTTON_TEXT }}
+ </gl-button>
+ </slot>
<gl-button
:disabled="submitDisabled"
:loading="isLoading"
- variant="success"
+ variant="confirm"
data-qa-selector="invite_button"
data-testid="invite-button"
@click="submit"
>
- {{ $options.INVITE_BUTTON_TEXT }}
+ {{ submitButtonText }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/invite_members/init_invite_members_form.js b/app/assets/javascripts/invite_members/init_invite_members_form.js
deleted file mode 100644
index 5f8688755ba..00000000000
--- a/app/assets/javascripts/invite_members/init_invite_members_form.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
-
-// This is only used when `invite_members_group_modal` feature flag is disabled.
-// This file can be removed when `invite_members_group_modal` feature flag is removed
-export default () => {
- disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
-};
diff --git a/app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js b/app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js
new file mode 100644
index 00000000000..62f66d009dc
--- /dev/null
+++ b/app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js
@@ -0,0 +1,12 @@
+import { unescape } from 'lodash';
+import { sanitize } from '~/lib/dompurify';
+import { INVALID_FEEDBACK_MESSAGE_DEFAULT } from '../constants';
+import { responseMessageFromError } from './response_message_parser';
+
+const unescapeMsg = (message) => unescape(sanitize(message, { ALLOWED_TAGS: [] }));
+
+export const getInvalidFeedbackMessage = (response) => {
+ const message = unescapeMsg(responseMessageFromError(response));
+
+ return message || INVALID_FEEDBACK_MESSAGE_DEFAULT;
+};
diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue
index 512fa6f8c68..fcebae3af71 100644
--- a/app/assets/javascripts/issuable/components/issuable_by_email.vue
+++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue
@@ -65,7 +65,6 @@ export default {
const body = sprintf(__('Enter the %{name} description'), {
name: this.issuableName,
});
- // eslint-disable-next-line @gitlab/require-i18n-strings
return `mailto:${this.email}?subject=${subject}&body=${body}`;
},
},
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index a3752c7043c..247f8dd0bd6 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -10,6 +10,7 @@ import ISetter from '~/filtered_search/droplab/plugins/input_setter';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = { ...ISetter };
@@ -171,12 +172,21 @@ export default class CreateMergeRequestDropdown {
this.isCreatingMergeRequest = true;
return this.createBranch().then(() => {
- window.location.href = canCreateConfidentialMergeRequest()
+ let path = canCreateConfidentialMergeRequest()
? this.createMrPath.replace(
this.projectPath,
confidentialMergeRequestState.selectedProject.pathWithNamespace,
)
: this.createMrPath;
+ path = mergeUrlParams(
+ {
+ 'merge_request[target_branch]': this.refInput.value,
+ 'merge_request[source_branch]': this.branchInput.value,
+ },
+ path,
+ );
+
+ window.location.href = path;
});
});
}
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 3866a7b3305..a532fa5b771 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -39,8 +39,11 @@ import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/con
import {
CREATED_DESC,
i18n,
+ ISSUE_REFERENCE,
MAX_LIST_SIZE,
PAGE_SIZE,
+ PARAM_PAGE_AFTER,
+ PARAM_PAGE_BEFORE,
PARAM_STATE,
RELATIVE_POSITION_ASC,
TOKEN_TYPE_ASSIGNEE,
@@ -134,6 +137,8 @@ export default {
},
},
data() {
+ const pageAfter = getParameterByName(PARAM_PAGE_AFTER);
+ const pageBefore = getParameterByName(PARAM_PAGE_BEFORE);
const state = getParameterByName(PARAM_STATE);
const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
const dashboardSortKey = getSortKey(this.initialSort);
@@ -165,7 +170,7 @@ export default {
issuesCounts: {},
issuesError: null,
pageInfo: {},
- pageParams: getInitialPageParams(sortKey),
+ pageParams: getInitialPageParams(sortKey, pageAfter, pageBefore),
showBulkEditSidebar: false,
sortKey,
state: state || IssuableStates.Opened,
@@ -219,11 +224,13 @@ export default {
},
computed: {
queryVariables() {
+ const isIidSearch = ISSUE_REFERENCE.test(this.searchQuery);
return {
fullPath: this.fullPath,
+ iid: isIidSearch ? this.searchQuery.slice(1) : undefined,
isProject: this.isProject,
isSignedIn: this.isSignedIn,
- search: this.searchQuery,
+ search: isIidSearch ? undefined : this.searchQuery,
sort: this.sortKey,
state: this.state,
...this.pageParams,
@@ -234,7 +241,12 @@ export default {
return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
},
hasSearch() {
- return this.searchQuery || Object.keys(this.urlFilterParams).length;
+ return (
+ this.searchQuery ||
+ Object.keys(this.urlFilterParams).length ||
+ this.pageParams.afterCursor ||
+ this.pageParams.beforeCursor
+ );
},
isBulkEditButtonDisabled() {
return this.showBulkEditSidebar || !this.issues.length;
@@ -391,6 +403,8 @@ export default {
},
urlParams() {
return {
+ page_after: this.pageParams.afterCursor,
+ page_before: this.pageParams.beforeCursor,
search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 284167a933f..4b07a078512 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -52,20 +52,15 @@ export const i18n = {
upvotes: __('Upvotes'),
};
+export const ISSUE_REFERENCE = /^#\d+$/;
export const MAX_LIST_SIZE = 10;
export const PAGE_SIZE = 20;
export const PAGE_SIZE_MANUAL = 100;
+export const PARAM_PAGE_AFTER = 'page_after';
+export const PARAM_PAGE_BEFORE = 'page_before';
export const PARAM_STATE = 'state';
export const RELATIVE_POSITION = 'relative_position';
-export const defaultPageSizeParams = {
- firstPageSize: PAGE_SIZE,
-};
-
-export const largePageSizeParams = {
- firstPageSize: PAGE_SIZE_MANUAL,
-};
-
export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC';
export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC';
export const CREATED_ASC = 'CREATED_ASC';
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 be8deb3fe97..529262d2162 100644
--- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
@@ -5,6 +5,7 @@ query getIssues(
$isProject: Boolean = false
$isSignedIn: Boolean = false
$fullPath: ID!
+ $iid: String
$search: String
$sort: IssueSort
$state: IssuableState
@@ -29,6 +30,7 @@ query getIssues(
id
issues(
includeSubgroups: true
+ iid: $iid
search: $search
sort: $sort
state: $state
@@ -59,6 +61,7 @@ query getIssues(
project(fullPath: $fullPath) @include(if: $isProject) {
id
issues(
+ iid: $iid
search: $search
sort: $sort
state: $state
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
index 1a345fd2877..58e7ce32e7c 100644
--- a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
@@ -1,6 +1,7 @@
query getIssuesCount(
$isProject: Boolean = false
$fullPath: ID!
+ $iid: String
$search: String
$assigneeId: String
$assigneeUsernames: [String!]
@@ -20,6 +21,7 @@ query getIssuesCount(
openedIssues: issues(
includeSubgroups: true
state: opened
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
@@ -37,6 +39,7 @@ query getIssuesCount(
closedIssues: issues(
includeSubgroups: true
state: closed
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
@@ -54,6 +57,7 @@ query getIssuesCount(
allIssues: issues(
includeSubgroups: true
state: all
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
@@ -73,6 +77,7 @@ query getIssuesCount(
id
openedIssues: issues(
state: opened
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
@@ -91,6 +96,7 @@ query getIssuesCount(
}
closedIssues: issues(
state: closed
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
@@ -109,6 +115,7 @@ query getIssuesCount(
}
allIssues: issues(
state: all
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
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 92517ad35d0..46b48e4e41c 100644
--- a/app/assets/javascripts/issues/list/queries/search_users.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_users.query.graphql
@@ -3,7 +3,7 @@
query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
id
- groupMembers(search: $search) {
+ groupMembers(search: $search, relations: [DIRECT, INHERITED, SHARED_FROM_GROUPS]) {
nodes {
id
user {
@@ -14,7 +14,7 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false)
}
project(fullPath: $fullPath) @include(if: $isProject) {
id
- projectMembers(search: $search) {
+ projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
nodes {
id
user {
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index 6322968b3f0..4b77bd9bc5f 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -10,16 +10,16 @@ import {
BLOCKING_ISSUES_DESC,
CREATED_ASC,
CREATED_DESC,
- defaultPageSizeParams,
DUE_DATE_ASC,
DUE_DATE_DESC,
filters,
LABEL_PRIORITY_ASC,
LABEL_PRIORITY_DESC,
- largePageSizeParams,
MILESTONE_DUE_ASC,
MILESTONE_DUE_DESC,
NORMAL_FILTER,
+ PAGE_SIZE,
+ PAGE_SIZE_MANUAL,
POPULARITY_ASC,
POPULARITY_DESC,
PRIORITY_ASC,
@@ -43,8 +43,11 @@ import {
WEIGHT_DESC,
} from './constants';
-export const getInitialPageParams = (sortKey) =>
- sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams;
+export const getInitialPageParams = (sortKey, afterCursor, beforeCursor) => ({
+ firstPageSize: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
+ afterCursor,
+ beforeCursor,
+});
export const getSortKey = (sort) =>
Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort);
diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
index 26862346b86..47b09bd6aa0 100644
--- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
+++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
@@ -31,7 +31,10 @@ export default {
computed: {
actionPrimary() {
return {
- attributes: { variant: 'danger' },
+ attributes: {
+ variant: 'danger',
+ 'data-qa-selector': 'confirm_delete_issue_button',
+ },
text: this.title,
};
},
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index eeccf886b65..68ed7bb4062 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -10,7 +10,9 @@ import $ from 'jquery';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import TaskList from '~/task_list';
+import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import animateMixin from '../mixins/animate';
@@ -24,8 +26,9 @@ export default {
GlPopover,
CreateWorkItem,
GlButton,
+ WorkItemDetailModal,
},
- mixins: [animateMixin, glFeatureFlagMixin()],
+ mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
props: {
canUpdate: {
type: Boolean,
@@ -68,9 +71,13 @@ export default {
initialUpdate: true,
taskButtons: [],
activeTask: {},
+ workItemId: null,
};
},
computed: {
+ showWorkItemDetailModal() {
+ return Boolean(this.workItemId);
+ },
workItemsEnabled() {
return this.glFeatures.workItems;
},
@@ -194,7 +201,13 @@ export default {
closeCreateTaskModal() {
this.$refs.modal.hide();
},
- handleCreateTask(title) {
+ closeWorkItemDetailModal() {
+ this.workItemId = null;
+ },
+ handleWorkItemDetailModalError(message) {
+ createFlash({ message });
+ },
+ handleCreateTask({ id, title, type }) {
const listItem = this.$el.querySelector(`#${this.activeTask.id}`).parentElement;
const taskBadge = document.createElement('span');
taskBadge.innerHTML = `
@@ -204,12 +217,28 @@ export default {
<span class="badge badge-info badge-pill gl-badge sm gl-mr-1">
${__('Task')}
</span>
- <a href="#">${title}</a>
`;
+ const button = this.createWorkItemDetailButton(id, title, type);
+ taskBadge.append(button);
+
listItem.insertBefore(taskBadge, listItem.lastChild);
listItem.removeChild(listItem.lastChild);
this.closeCreateTaskModal();
},
+ createWorkItemDetailButton(id, title, type) {
+ const button = document.createElement('button');
+ button.addEventListener('click', () => {
+ this.workItemId = id;
+ this.track('viewed_work_item_from_modal', {
+ category: 'workItems:show',
+ label: 'work_item_view',
+ property: `type_${type}`,
+ });
+ });
+ button.classList.add('btn-link');
+ button.innerText = title;
+ return button;
+ },
focusButton() {
this.$refs.convertButton[0].$el.focus();
},
@@ -262,6 +291,12 @@ export default {
@onCreate="handleCreateTask"
/>
</gl-modal>
+ <work-item-detail-modal
+ :visible="showWorkItemDetailModal"
+ :work-item-id="workItemId"
+ @close="closeWorkItemDetailModal"
+ @error="handleWorkItemDetailModalError"
+ />
<template v-if="workItemsEnabled">
<gl-popover
v-for="item in taskButtons"
diff --git a/app/assets/javascripts/issues/show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue
index 9ce49b65a1a..d528641dcb6 100644
--- a/app/assets/javascripts/issues/show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue
@@ -68,7 +68,10 @@ export default {
data-toggle="dropdown"
>
<span class="dropdown-toggle-text">{{ __('Choose a template') }}</span>
- <gl-icon name="chevron-down" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" />
+ <gl-icon
+ name="chevron-down"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500 dropdown-menu-toggle-icon"
+ />
</button>
<div class="dropdown-menu dropdown-select">
<div class="dropdown-title gl-display-flex gl-justify-content-center">
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 8ba08472ea0..adf449aca7b 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -128,13 +128,21 @@ export default {
});
},
newIssueTypeText() {
- return sprintf(__('New %{issueType}'), { issueType: this.issueType });
+ return sprintf(__('New related %{issueType}'), { issueType: this.issueType });
},
showToggleIssueStateButton() {
const canClose = !this.isClosed && this.canUpdateIssue;
const canReopen = this.isClosed && this.canReopenIssue;
return canClose || canReopen;
},
+ hasDesktopDropdown() {
+ return (
+ this.canCreateIssue || this.canPromoteToEpic || !this.isIssueAuthor || this.canReportSpam
+ );
+ },
+ hasMobileDropdown() {
+ return this.hasDesktopDropdown || this.showToggleIssueStateButton;
+ },
},
created() {
eventHub.$on('toggle.issuable.state', this.toggleIssueState);
@@ -223,10 +231,12 @@ export default {
<template>
<div class="detail-page-header-actions gl-display-flex">
<gl-dropdown
+ v-if="hasMobileDropdown"
class="gl-sm-display-none! w-100"
block
:text="dropdownText"
data-qa-selector="issue_actions_dropdown"
+ data-testid="mobile-dropdown"
:loading="isToggleStateButtonLoading"
>
<gl-dropdown-item
@@ -276,11 +286,14 @@ export default {
</gl-button>
<gl-dropdown
+ v-if="hasDesktopDropdown"
class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
icon="ellipsis_v"
category="tertiary"
+ data-qa-selector="issue_actions_ellipsis_dropdown"
:text="dropdownText"
:text-sr-only="true"
+ data-testid="desktop-dropdown"
no-caret
right
>
@@ -311,6 +324,7 @@ export default {
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
+ data-qa-selector="delete_issue_button"
@click="track('click_dropdown')"
>
{{ deleteButtonText }}
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index 4790062ab7d..04ddc7f3501 100644
--- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -5,6 +5,7 @@ import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DescriptionComponent from '../description.vue';
import getAlert from './graphql/queries/get_alert.graphql';
import HighlightBar from './highlight_bar.vue';
@@ -17,7 +18,10 @@ export default {
GlTabs,
HighlightBar,
MetricsTab: () => import('ee_component/issues/show/components/incidents/metrics_tab.vue'),
+ TimelineTab: () =>
+ import('ee_component/issues/show/components/incidents/timeline_events_tab.vue'),
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'],
apollo: {
alert: {
@@ -47,6 +51,9 @@ export default {
loading() {
return this.$apollo.queries.alert.loading;
},
+ incidentTabEnabled() {
+ return this.glFeatures.incidentTimelineEvents && this.glFeatures.incidentTimelineEventTab;
+ },
},
mounted() {
this.trackPageViews();
@@ -76,6 +83,7 @@ export default {
>
<alert-details-table :alert="alert" :loading="loading" />
</gl-tab>
+ <timeline-tab v-if="incidentTabEnabled" data-testid="timeline-events-tab" />
</gl-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
index 5e92211685a..1982147e454 100644
--- a/app/assets/javascripts/issues/show/components/title.vue
+++ b/app/assets/javascripts/issues/show/components/title.vue
@@ -68,7 +68,7 @@ export default {
<template>
<div class="title-container">
- <h2
+ <h1
v-safe-html="titleHtml"
:class="{
'issue-realtime-pre-pulse': preAnimation,
@@ -76,7 +76,7 @@ export default {
}"
class="title qa-title"
dir="auto"
- ></h2>
+ ></h1>
<gl-button
v-if="showInlineEditButton && canUpdate"
v-gl-tooltip.bottom
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index f5c71f9691f..c9af5d9b4a7 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -77,9 +77,7 @@ export function initIssueApp(issueData, store) {
const { fullPath } = el.dataset;
- if (gon?.features?.fixCommentScroll) {
- scrollToTargetOnResize();
- }
+ scrollToTargetOnResize();
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 905e242e977..afdb414e82c 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -3,6 +3,7 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapMutations } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
+import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '../constants';
import { SET_ALERT } from '../store/mutation_types';
import SignInPage from '../pages/sign_in.vue';
import SubscriptionsPage from '../pages/subscriptions.vue';
@@ -28,6 +29,11 @@ export default {
default: [],
},
},
+ data() {
+ return {
+ user: null,
+ };
+ },
computed: {
...mapState(['alert']),
shouldShowAlert() {
@@ -37,7 +43,7 @@ export default {
return !isEmpty(this.subscriptions);
},
userSignedIn() {
- return Boolean(!this.usersPath);
+ return Boolean(!this.usersPath || this.user);
},
},
created() {
@@ -51,6 +57,15 @@ export default {
const { linkUrl, title, message, variant } = retrieveAlert() || {};
this.setAlert({ linkUrl, title, message, variant });
},
+ onSignInOauth(user) {
+ this.user = user;
+ },
+ onSignInError() {
+ this.setAlert({
+ message: I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE,
+ variant: 'danger',
+ });
+ },
},
};
</script>
@@ -78,11 +93,16 @@ export default {
</template>
</gl-alert>
- <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" />
+ <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" :user="user" />
<h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
<div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
- <sign-in-page v-if="!userSignedIn" :has-subscriptions="hasSubscriptions" />
+ <sign-in-page
+ v-if="!userSignedIn"
+ :has-subscriptions="hasSubscriptions"
+ @sign-in-oauth="onSignInOauth"
+ @error="onSignInError"
+ />
<subscriptions-page v-else :has-subscriptions="hasSubscriptions" />
</div>
</div>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue
index 627abcdd4a0..ec718d5b3ca 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
-import { s__ } from '~/locale';
+import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/constants';
export default {
components: {
@@ -27,7 +27,7 @@ export default {
},
},
i18n: {
- defaultButtonText: s__('Integrations|Sign in to GitLab'),
+ defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
},
};
</script>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
new file mode 100644
index 00000000000..d7ec909cb28
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import {
+ I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
+ OAUTH_WINDOW_OPTIONS,
+ PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM,
+} from '~/jira_connect/subscriptions/constants';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import AccessorUtilities from '~/lib/utils/accessor';
+
+import { createCodeVerifier, createCodeChallenge } from '../pkce';
+
+export default {
+ components: {
+ GlButton,
+ },
+ inject: ['oauthMetadata'],
+ data() {
+ return {
+ token: null,
+ loading: false,
+ codeVerifier: null,
+ canUseCrypto: AccessorUtilities.canUseCrypto(),
+ };
+ },
+ mounted() {
+ window.addEventListener('message', this.handleWindowMessage);
+ },
+ beforeDestroy() {
+ window.removeEventListener('message', this.handleWindowMessage);
+ },
+ methods: {
+ async startOAuthFlow() {
+ this.loading = true;
+
+ // Generate state necessary for PKCE OAuth flow
+ this.codeVerifier = createCodeVerifier();
+ const codeChallenge = await createCodeChallenge(this.codeVerifier);
+
+ // Build the initial OAuth authorization URL
+ const { oauth_authorize_url: oauthAuthorizeURL } = this.oauthMetadata;
+ const oauthAuthorizeURLWithChallenge = setUrlParams(
+ {
+ code_challenge: codeChallenge,
+ code_challenge_method: PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.short,
+ },
+ oauthAuthorizeURL,
+ );
+
+ window.open(
+ oauthAuthorizeURLWithChallenge,
+ this.$options.i18n.defaultButtonText,
+ OAUTH_WINDOW_OPTIONS,
+ );
+ },
+ async handleWindowMessage(event) {
+ if (window.origin !== event.origin) {
+ this.loading = false;
+ this.handleError();
+ return;
+ }
+
+ // Verify that OAuth state isn't altered.
+ const state = event.data?.state;
+ if (state !== this.oauthMetadata.state) {
+ this.loading = false;
+ this.handleError();
+ return;
+ }
+
+ // Request access token and load the authenticated user.
+ const code = event.data?.code;
+ try {
+ const accessToken = await this.getOAuthToken(code);
+ await this.loadUser(accessToken);
+ } catch (e) {
+ this.handleError();
+ } finally {
+ this.loading = false;
+ }
+ },
+ handleError() {
+ this.$emit('error');
+ },
+ async getOAuthToken(code) {
+ const {
+ oauth_token_payload: oauthTokenPayload,
+ oauth_token_url: oauthTokenURL,
+ } = this.oauthMetadata;
+ const { data } = await axios.post(oauthTokenURL, {
+ ...oauthTokenPayload,
+ code,
+ code_verifier: this.codeVerifier,
+ });
+
+ return data.access_token;
+ },
+ async loadUser(accessToken) {
+ const { data } = await axios.get('/api/v4/user', {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+
+ this.$emit('sign-in', data);
+ },
+ },
+ i18n: {
+ defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
+ },
+};
+</script>
+<template>
+ <gl-button
+ category="primary"
+ variant="info"
+ :loading="loading"
+ :disabled="!canUseCrypto"
+ @click="startOAuthFlow"
+ >
+ <slot>
+ {{ $options.i18n.defaultButtonText }}
+ </slot>
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
index fad3d2616d8..5e2c83aff65 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
@@ -25,6 +25,11 @@ export default {
type: Boolean,
required: true,
},
+ user: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -32,8 +37,19 @@ export default {
};
},
computed: {
+ gitlabUserName() {
+ return gon.current_username ?? this.user?.username;
+ },
gitlabUserHandle() {
- return `@${gon.current_username}`;
+ return this.gitlabUserName ? `@${this.gitlabUserName}` : undefined;
+ },
+ gitlabUserLink() {
+ return this.gitlabUserPath ?? `${gon.relative_root_url}/${this.gitlabUserName}`;
+ },
+ signedInText() {
+ return this.gitlabUserHandle
+ ? this.$options.i18n.signedInAsUserText
+ : this.$options.i18n.signedInText;
},
},
async created() {
@@ -42,14 +58,15 @@ export default {
i18n: {
signInText: __('Sign in to GitLab'),
signedInAsUserText: __('Signed in to GitLab as %{user_link}'),
+ signedInText: __('Signed in to GitLab'),
},
};
</script>
<template>
<div class="jira-connect-user gl-font-base">
- <gl-sprintf v-if="userSignedIn" :message="$options.i18n.signedInAsUserText">
+ <gl-sprintf v-if="userSignedIn" :message="signedInText">
<template #user_link>
- <gl-link data-testid="gitlab-user-link" :href="gitlabUserPath" target="_blank">
+ <gl-link data-testid="gitlab-user-link" :href="gitlabUserLink" target="_blank">
{{ gitlabUserHandle }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js
index 2a65b7bc1fa..d30ebdbb487 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/constants.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js
@@ -1,5 +1,26 @@
+import { s__ } from '~/locale';
+
export const DEFAULT_GROUPS_PER_PAGE = 10;
export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert';
export const MINIMUM_SEARCH_TERM_LENGTH = 3;
export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal';
+
+export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to GitLab');
+export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.');
+
+const OAUTH_WINDOW_SIZE = 800;
+export const OAUTH_WINDOW_OPTIONS = [
+ 'resizable=yes',
+ 'scrollbars=yes',
+ 'status=yes',
+ `width=${OAUTH_WINDOW_SIZE}`,
+ `height=${OAUTH_WINDOW_SIZE}`,
+ `left=${window.screen.width / 2 - OAUTH_WINDOW_SIZE / 2}`,
+ `top=${window.screen.height / 2 - OAUTH_WINDOW_SIZE / 2}`,
+].join(',');
+
+export const PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM = {
+ long: 'SHA-256',
+ short: 'S256',
+};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js
index cd1fc1d4455..320f0f8aa6c 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/index.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/index.js
@@ -21,7 +21,14 @@ export function initJiraConnect() {
Vue.use(Translate);
Vue.use(GlFeatureFlagsPlugin);
- const { groupsPath, subscriptions, subscriptionsPath, usersPath, gitlabUserPath } = el.dataset;
+ const {
+ groupsPath,
+ subscriptions,
+ subscriptionsPath,
+ usersPath,
+ gitlabUserPath,
+ oauthMetadata,
+ } = el.dataset;
sizeToParent();
return new Vue({
@@ -33,6 +40,7 @@ export function initJiraConnect() {
subscriptionsPath,
usersPath,
gitlabUserPath,
+ oauthMetadata: oauthMetadata ? JSON.parse(oauthMetadata) : null,
},
render(createElement) {
return createElement(JiraConnectApp);
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue
index 2bce5afc72b..a24ee33b723 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue
@@ -1,14 +1,17 @@
<script>
import { s__ } from '~/locale';
+
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SubscriptionsList from '../components/subscriptions_list.vue';
-import SignInButton from '../components/sign_in_button.vue';
export default {
name: 'SignInPage',
components: {
SubscriptionsList,
- SignInButton,
+ SignInLegacyButton: () => import('../components/sign_in_legacy_button.vue'),
+ SignInOauthButton: () => import('../components/sign_in_oauth_button.vue'),
},
+ mixins: [glFeatureFlagMixin()],
inject: ['usersPath'],
props: {
hasSubscriptions: {
@@ -16,25 +19,47 @@ export default {
required: true,
},
},
+ computed: {
+ useSignInOauthButton() {
+ return this.glFeatures.jiraConnectOauth;
+ },
+ },
i18n: {
- signinButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
+ signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
signInText: s__('JiraService|Sign in to GitLab.com to get started.'),
},
+ methods: {
+ onSignInError() {
+ this.$emit('error');
+ },
+ },
};
</script>
<template>
<div v-if="hasSubscriptions">
<div class="gl-display-flex gl-justify-content-end">
- <sign-in-button :users-path="usersPath">
- {{ $options.i18n.signinButtonTextWithSubscriptions }}
- </sign-in-button>
+ <sign-in-oauth-button
+ v-if="useSignInOauthButton"
+ @sign-in="$emit('sign-in-oauth', $event)"
+ @error="onSignInError"
+ >
+ {{ $options.i18n.signInButtonTextWithSubscriptions }}
+ </sign-in-oauth-button>
+ <sign-in-legacy-button v-else :users-path="usersPath">
+ {{ $options.i18n.signInButtonTextWithSubscriptions }}
+ </sign-in-legacy-button>
</div>
<subscriptions-list />
</div>
<div v-else class="gl-text-center">
<p class="gl-mb-7">{{ $options.i18n.signInText }}</p>
- <sign-in-button class="gl-mb-7" :users-path="usersPath" />
+ <sign-in-oauth-button
+ v-if="useSignInOauthButton"
+ @sign-in="$emit('sign-in-oauth', $event)"
+ @error="onSignInError"
+ />
+ <sign-in-legacy-button v-else class="gl-mb-7" :users-path="usersPath" />
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pkce.js b/app/assets/javascripts/jira_connect/subscriptions/pkce.js
new file mode 100644
index 00000000000..18ea5cae860
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/pkce.js
@@ -0,0 +1,60 @@
+import { bufferToBase64, base64ToBase64Url } from '~/authentication/webauthn/util';
+import { PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM } from './constants';
+
+// PKCE codeverifier should have a maximum length of 128 characters.
+// Using 96 bytes generates a string of 128 characters.
+// RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
+export const CODE_VERIFIER_BYTES = 96;
+
+/**
+ * Generate a cryptographically random string.
+ * @param {Number} lengthBytes
+ * @returns {String} a random string
+ */
+function getRandomString(lengthBytes) {
+ // generate random values and load them into byteArray.
+ const byteArray = new Uint8Array(lengthBytes);
+ window.crypto.getRandomValues(byteArray);
+
+ // Convert array to string
+ const randomString = bufferToBase64(byteArray);
+ return randomString;
+}
+
+/**
+ * Creates a code verifier to be used for OAuth PKCE authentication.
+ * The code verifier has 128 characters.
+ *
+ * RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
+ * @returns {String} code verifier
+ */
+export function createCodeVerifier() {
+ const verifier = getRandomString(CODE_VERIFIER_BYTES);
+ return base64ToBase64Url(verifier);
+}
+
+/**
+ * Creates a code challenge for OAuth PKCE authentication.
+ * The code challenge is derived from the given [codeVerifier].
+ * [codeVerifier] is tranformed in the following way (as per the RFC):
+ * code_challenge = BASE64URL-ENCODE(SHA256(ASCII(codeVerifier)))
+ *
+ * RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
+ * @param {String} codeVerifier
+ * @returns {String} code challenge
+ */
+export async function createCodeChallenge(codeVerifier) {
+ // Generate SHA-256 digest of the [codeVerifier]
+ const buffer = new TextEncoder().encode(codeVerifier);
+ const digestArrayBuffer = await window.crypto.subtle.digest(
+ PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.long,
+ buffer,
+ );
+
+ // Convert digest to a Base64URL-encoded string
+ const digestHash = bufferToBase64(digestArrayBuffer);
+ // Escape string to remove reserved charaters
+ const codeChallenge = base64ToBase64Url(digestHash);
+
+ return codeChallenge;
+}
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index fe4158a1bd1..85fe5ed7e26 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -3,7 +3,6 @@ import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml, GlAlert } from
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
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 CiHeader from '~/vue_shared/components/header_ci_component.vue';
@@ -33,7 +32,6 @@ export default {
GlLoadingIcon,
SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'),
GlAlert,
- CodeQualityWalkthrough,
},
directives: {
SafeHtml,
@@ -69,11 +67,6 @@ export default {
required: false,
default: null,
},
- codeQualityHelpUrl: {
- type: String,
- required: false,
- default: null,
- },
},
computed: {
...mapState([
@@ -123,9 +116,6 @@ export default {
return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure;
},
- shouldRenderCodeQualityWalkthrough() {
- return this.job.status.group === 'failed-with-warnings';
- },
itemName() {
return sprintf(__('Job %{jobName}'), { jobName: this.job.name });
},
@@ -224,11 +214,6 @@ export default {
>
<div v-safe-html="job.callout_message"></div>
</gl-alert>
- <code-quality-walkthrough
- v-if="shouldRenderCodeQualityWalkthrough"
- step="troubleshoot_job"
- :link="codeQualityHelpUrl"
- />
</header>
<!-- EO Header Section -->
@@ -288,7 +273,6 @@ export default {
'sidebar-collapsed': !isSidebarOpen,
'has-archived-block': job.archived,
}"
- :erase-path="job.erase_path"
:size="jobLogSize"
:raw-path="job.raw_path"
:is-scroll-bottom-disabled="isScrollBottomDisabled"
@@ -325,6 +309,7 @@ export default {
'right-sidebar-expanded': isSidebarOpen,
'right-sidebar-collapsed': !isSidebarOpen,
}"
+ :erase-path="job.erase_path"
:artifact-help-url="artifactHelpUrl"
data-testid="job-sidebar"
/>
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index 8e35fd91481..eb6a284dfaf 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -5,7 +5,6 @@ import { __, s__, sprintf } from '~/locale';
export default {
i18n: {
- eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
scrollToTopButtonLabel: s__('Job|Scroll to top'),
showRawButtonLabel: s__('Job|Show complete raw'),
@@ -18,11 +17,6 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
- erasePath: {
- type: String,
- required: false,
- default: null,
- },
size: {
type: Number,
required: true,
@@ -97,20 +91,6 @@ export default {
data-testid="job-raw-link-controller"
icon="doc-text"
/>
-
- <gl-button
- v-if="erasePath"
- v-gl-tooltip.body
- :title="$options.i18n.eraseLogButtonLabel"
- :aria-label="$options.i18n.eraseLogButtonLabel"
- :href="erasePath"
- :data-confirm="__('Are you sure you want to erase this build?')"
- class="gl-ml-3"
- data-testid="job-log-erase-link"
- data-confirm-btn-variant="danger"
- data-method="post"
- icon="remove"
- />
<!-- eo links -->
<!-- scroll buttons -->
diff --git a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
index a43b3297d75..a7bf365d35c 100644
--- a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
+++ b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlLink, GlModalDirective } from '@gitlab/ui';
+import { GlButton, GlModalDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { JOB_SIDEBAR } from '../constants';
@@ -10,7 +10,6 @@ export default {
},
components: {
GlButton,
- GlLink,
},
directives: {
GlModal: GlModalDirective,
@@ -37,9 +36,18 @@ export default {
:aria-label="$options.i18n.retryLabel"
category="primary"
variant="confirm"
- >{{ $options.i18n.retryLabel }}</gl-button
- >
- <gl-link v-else :href="href" class="btn gl-button btn-confirm" data-method="post" rel="nofollow"
- >{{ $options.i18n.retryLabel }}
- </gl-link>
+ icon="retry"
+ data-testid="retry-job-button"
+ />
+
+ <gl-button
+ v-else
+ :href="href"
+ :aria-label="$options.i18n.retryLabel"
+ category="primary"
+ variant="confirm"
+ icon="retry"
+ data-method="post"
+ data-testid="retry-job-link"
+ />
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/jobs/components/log/line_header.vue
index 3bb1f58573c..c72d488f844 100644
--- a/app/assets/javascripts/jobs/components/log/line_header.vue
+++ b/app/assets/javascripts/jobs/components/log/line_header.vue
@@ -43,7 +43,7 @@ export default {
<template>
<div
- class="log-line collapsible-line d-flex justify-content-between ws-normal"
+ class="log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start"
role="button"
@click="handleOnClick"
>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index 9aa1503c7c3..1b4c9ebdf7d 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -1,7 +1,8 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
+import { s__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { JOB_SIDEBAR } from '../constants';
import ArtifactsBlock from './artifacts_block.vue';
@@ -18,10 +19,17 @@ export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
export default {
name: 'JobSidebar',
i18n: {
+ eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
+ eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'),
+ cancelJobButtonLabel: s__('Job|Cancel'),
+ retryJobButtonLabel: s__('Job|Retry'),
...JOB_SIDEBAR,
},
borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
forwardDeploymentFailureModalId,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
components: {
ArtifactsBlock,
CommitBlock,
@@ -41,6 +49,11 @@ export default {
required: false,
default: '',
},
+ erasePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
...mapGetters(['hasForwardDeploymentFailure']),
@@ -81,8 +94,24 @@ export default {
</h4>
</tooltip-on-truncate>
<div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
+ <gl-button
+ v-if="erasePath"
+ v-gl-tooltip.left
+ :title="$options.i18n.eraseLogButtonLabel"
+ :aria-label="$options.i18n.eraseLogButtonLabel"
+ :href="erasePath"
+ :data-confirm="$options.i18n.eraseLogConfirmText"
+ class="gl-mr-2"
+ data-testid="job-log-erase-link"
+ data-confirm-btn-variant="danger"
+ data-method="post"
+ icon="remove"
+ />
<job-sidebar-retry-button
v-if="job.retry_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.retryJobButtonLabel"
+ :aria-label="$options.i18n.retryJobButtonLabel"
:category="retryButtonCategory"
:href="job.retry_path"
:modal-id="$options.forwardDeploymentFailureModalId"
@@ -92,12 +121,15 @@ export default {
/>
<gl-button
v-if="job.cancel_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.cancelJobButtonLabel"
+ :aria-label="$options.i18n.cancelJobButtonLabel"
:href="job.cancel_path"
+ icon="cancel"
data-method="post"
data-testid="cancel-button"
rel="nofollow"
- >{{ $options.i18n.cancel }}
- </gl-button>
+ />
</div>
<gl-button
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 1780afd39e8..7c4811b2d6f 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -1,8 +1,12 @@
<script>
-import { GlLink, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlLink, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
+import Mousetrap from 'mousetrap';
+import { s__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard';
+import { keysFor, MR_COPY_SOURCE_BRANCH_NAME } from '~/behaviors/shortcuts/keybindings';
export default {
components: {
@@ -11,6 +15,7 @@ export default {
GlDropdown,
GlDropdownItem,
GlLink,
+ GlSprintf,
},
props: {
pipeline: {
@@ -36,11 +41,43 @@ export default {
isMergeRequestPipeline() {
return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline);
},
+ pipelineInfo() {
+ if (!this.hasRef) {
+ return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id}');
+ } else if (!this.isTriggeredByMergeRequest) {
+ return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{ref}');
+ } else if (!this.isMergeRequestPipeline) {
+ return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source}');
+ }
+
+ return s__(
+ 'Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source} into %{target}',
+ );
+ },
+ },
+ mounted() {
+ Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), this.handleKeyboardCopy);
+ },
+ beforeDestroy() {
+ Mousetrap.unbind(keysFor(MR_COPY_SOURCE_BRANCH_NAME));
},
methods: {
onStageClick(stage) {
this.$emit('requestSidebarStageDropdown', stage);
},
+ handleKeyboardCopy() {
+ let button;
+
+ if (!this.hasRef) {
+ return;
+ } else if (!this.isTriggeredByMergeRequest) {
+ button = this.$refs['copy-source-ref-link'];
+ } else {
+ button = this.$refs['copy-source-branch-link'];
+ }
+
+ clickCopyToClipboardButton(button.$el);
+ },
},
};
</script>
@@ -48,54 +85,72 @@ export default {
<div class="dropdown">
<div class="js-pipeline-info" data-testid="pipeline-info">
<ci-icon :status="pipeline.details.status" />
-
- <span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span>
- <gl-link
- :href="pipeline.path"
- class="js-pipeline-path link-commit"
- data-testid="pipeline-path"
- data-qa-selector="pipeline_path"
- >#{{ pipeline.id }}</gl-link
- >
- <template v-if="hasRef">
- {{ s__('Job|for') }}
-
- <template v-if="isTriggeredByMergeRequest">
+ <gl-sprintf :message="pipelineInfo">
+ <template #bold="{ content }">
+ <span class="font-weight-bold">{{ content }}</span>
+ </template>
+ <template #id>
+ <gl-link
+ :href="pipeline.path"
+ class="js-pipeline-path link-commit"
+ data-testid="pipeline-path"
+ data-qa-selector="pipeline_path"
+ >#{{ pipeline.id }}</gl-link
+ >
+ </template>
+ <template #mrId>
<gl-link
:href="pipeline.merge_request.path"
class="link-commit ref-name"
data-testid="mr-link"
>!{{ pipeline.merge_request.iid }}</gl-link
>
- {{ s__('Job|with') }}
+ </template>
+ <template #ref>
+ <gl-link
+ :href="pipeline.ref.path"
+ class="link-commit ref-name"
+ data-testid="source-ref-link"
+ >{{ pipeline.ref.name }}</gl-link
+ ><clipboard-button
+ ref="copy-source-ref-link"
+ :text="pipeline.ref.name"
+ :title="__('Copy reference')"
+ category="tertiary"
+ size="small"
+ data-testid="copy-source-ref-link"
+ />
+ </template>
+ <template #source>
<gl-link
:href="pipeline.merge_request.source_branch_path"
class="link-commit ref-name"
data-testid="source-branch-link"
>{{ pipeline.merge_request.source_branch }}</gl-link
- >
-
- <template v-if="isMergeRequestPipeline">
- {{ s__('Job|into') }}
- <gl-link
- :href="pipeline.merge_request.target_branch_path"
- class="link-commit ref-name"
- data-testid="target-branch-link"
- >{{ pipeline.merge_request.target_branch }}</gl-link
- >
- </template>
+ ><clipboard-button
+ ref="copy-source-branch-link"
+ :text="pipeline.merge_request.source_branch"
+ :title="__('Copy branch name')"
+ category="tertiary"
+ size="small"
+ data-testid="copy-source-branch-link"
+ />
+ </template>
+ <template #target>
+ <gl-link
+ :href="pipeline.merge_request.target_branch_path"
+ class="link-commit ref-name"
+ data-testid="target-branch-link"
+ >{{ pipeline.merge_request.target_branch }}</gl-link
+ ><clipboard-button
+ :text="pipeline.merge_request.target_branch"
+ :title="__('Copy branch name')"
+ category="tertiary"
+ size="small"
+ data-testid="copy-target-branch-link"
+ />
</template>
- <gl-link v-else :href="pipeline.ref.path" class="link-commit ref-name">{{
- pipeline.ref.name
- }}</gl-link
- ><clipboard-button
- :text="pipeline.ref.name"
- :title="__('Copy reference')"
- category="tertiary"
- size="small"
- data-testid="copy-source-ref-link"
- />
- </template>
+ </gl-sprintf>
</div>
<gl-dropdown :text="selectedStage" class="js-selected-stage gl-w-full gl-mt-3">
diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js
index 962979ba573..951d9324813 100644
--- a/app/assets/javascripts/jobs/components/table/constants.js
+++ b/app/assets/javascripts/jobs/components/table/constants.js
@@ -1,16 +1,6 @@
import { s__, __ } from '~/locale';
import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
-export const GRAPHQL_PAGE_SIZE = 30;
-
-export const initialPaginationState = {
- currentPage: 1,
- prevPageCursor: '',
- nextPageCursor: '',
- first: GRAPHQL_PAGE_SIZE,
- last: null,
-};
-
/* Error constants */
export const POST_FAILURE = 'post_failure';
export const DEFAULT = 'default';
diff --git a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
new file mode 100644
index 00000000000..b9946925c95
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
@@ -0,0 +1,30 @@
+import { isEqual } from 'lodash';
+
+export default {
+ typePolicies: {
+ Project: {
+ fields: {
+ jobs: {
+ keyArgs: false,
+ },
+ },
+ },
+ CiJobConnection: {
+ merge(existing = {}, incoming, { args = {} }) {
+ let nodes;
+
+ if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) {
+ nodes = [...existing.nodes, ...incoming.nodes];
+ } else {
+ nodes = [...incoming.nodes];
+ }
+
+ return {
+ nodes,
+ statuses: Array.isArray(args.statuses) ? [...args.statuses] : args.statuses,
+ pageInfo: incoming.pageInfo,
+ };
+ },
+ },
+ },
+};
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 88937185a8c..151e49af87e 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
@@ -1,25 +1,22 @@
-query getJobs(
- $fullPath: ID!
- $first: Int
- $last: Int
- $after: String
- $before: String
- $statuses: [CiJobStatus!]
-) {
+query getJobs($fullPath: ID!, $after: String, $statuses: [CiJobStatus!]) {
project(fullPath: $fullPath) {
id
- jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) {
+ __typename
+ jobs(after: $after, first: 30, statuses: $statuses) {
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
+ __typename
}
nodes {
+ __typename
artifacts {
nodes {
downloadPath
fileType
+ __typename
}
}
allowFailure
diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js
index f24daf90815..1b9c7cdcfdd 100644
--- a/app/assets/javascripts/jobs/components/table/index.js
+++ b/app/assets/javascripts/jobs/components/table/index.js
@@ -4,12 +4,18 @@ 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';
+import cacheConfig from './graphql/cache_config';
Vue.use(VueApollo);
Vue.use(GlToast);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig,
+ },
+ ),
});
export default (containerId = 'js-jobs-table') => {
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 81f42c1e293..864e322eecd 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -1,7 +1,6 @@
<script>
-import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
+import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } 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';
@@ -11,14 +10,16 @@ import JobsTableTabs from './jobs_table_tabs.vue';
export default {
i18n: {
errorMsg: __('There was an error fetching the jobs for your project.'),
+ loadingAriaLabel: __('Loading'),
},
components: {
GlAlert,
- GlPagination,
GlSkeletonLoader,
JobsTable,
JobsTableEmptyState,
JobsTableTabs,
+ GlIntersectionObserver,
+ GlLoadingIcon,
},
inject: {
fullPath: {
@@ -31,10 +32,6 @@ export default {
variables() {
return {
fullPath: this.fullPath,
- first: this.pagination.first,
- last: this.pagination.last,
- after: this.pagination.nextPageCursor,
- before: this.pagination.prevPageCursor,
};
},
update(data) {
@@ -57,7 +54,7 @@ export default {
hasError: false,
isAlertDismissed: false,
scope: null,
- pagination: initialPaginationState,
+ firstLoad: true,
};
},
computed: {
@@ -67,14 +64,8 @@ export default {
showEmptyState() {
return this.jobs.list.length === 0 && !this.scope;
},
- prevPage() {
- return Math.max(this.pagination.currentPage - 1, 0);
- },
- nextPage() {
- return this.jobs.pageInfo?.hasNextPage ? this.pagination.currentPage + 1 : null;
- },
- showPaginationControls() {
- return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading;
+ hasNextPage() {
+ return this.jobs?.pageInfo?.hasNextPage;
},
},
mounted() {
@@ -88,26 +79,22 @@ export default {
this.$apollo.queries.jobs.refetch({ statuses: this.scope });
},
fetchJobsByStatus(scope) {
+ this.firstLoad = true;
+
this.scope = scope;
this.$apollo.queries.jobs.refetch({ statuses: scope });
},
- handlePageChange(page) {
- const { startCursor, endCursor } = this.jobs.pageInfo;
+ fetchMoreJobs() {
+ this.firstLoad = false;
- if (page > this.pagination.currentPage) {
- this.pagination = {
- ...initialPaginationState,
- nextPageCursor: endCursor,
- currentPage: page,
- };
- } else {
- this.pagination = {
- last: GRAPHQL_PAGE_SIZE,
- first: null,
- prevPageCursor: startCursor,
- currentPage: page,
- };
+ if (!this.$apollo.queries.jobs.loading) {
+ this.$apollo.queries.jobs.fetchMore({
+ variables: {
+ fullPath: this.fullPath,
+ after: this.jobs?.pageInfo?.endCursor,
+ },
+ });
}
},
},
@@ -128,7 +115,7 @@ export default {
<jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" />
- <div v-if="$apollo.loading" class="gl-mt-5">
+ <div v-if="$apollo.loading && firstLoad" class="gl-mt-5">
<gl-skeleton-loader :width="1248" :height="73">
<circle cx="748.031" cy="37.7193" r="15.0307" />
<circle cx="787.241" cy="37.7193" r="15.0307" />
@@ -149,14 +136,12 @@ export default {
<jobs-table v-else :jobs="jobs.list" />
- <gl-pagination
- v-if="showPaginationControls"
- :value="pagination.currentPage"
- :prev-page="prevPage"
- :next-page="nextPage"
- align="center"
- class="gl-mt-3"
- @input="handlePageChange"
- />
+ <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
+ <gl-loading-icon
+ v-if="$apollo.loading"
+ size="md"
+ :aria-label="$options.i18n.loadingAriaLabel"
+ />
+ </gl-intersection-observer>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 6e958ea1842..26dd38bbe08 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -14,7 +14,6 @@ const initializeJobPage = (element) => {
const {
artifactHelpUrl,
deploymentHelpUrl,
- codeQualityHelpUrl,
runnerSettingsUrl,
subscriptionsMoreMinutesUrl,
endpoint,
@@ -39,7 +38,6 @@ const initializeJobPage = (element) => {
props: {
artifactHelpUrl,
deploymentHelpUrl,
- codeQualityHelpUrl,
runnerSettingsUrl,
subscriptionsMoreMinutesUrl,
endpoint,
diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js
index d4a6d70c62c..f7cdc564538 100644
--- a/app/assets/javascripts/lib/utils/accessor.js
+++ b/app/assets/javascripts/lib/utils/accessor.js
@@ -50,8 +50,16 @@ function canUseLocalStorage() {
return safe;
}
+/**
+ * Determines if `window.crypto` is available.
+ */
+function canUseCrypto() {
+ return window.crypto?.subtle !== undefined;
+}
+
const AccessorUtilities = {
canUseLocalStorage,
+ canUseCrypto,
};
export default AccessorUtilities;
diff --git a/app/assets/javascripts/lib/utils/array_utility.js b/app/assets/javascripts/lib/utils/array_utility.js
index 197e7790ed7..04f9cb1cdb5 100644
--- a/app/assets/javascripts/lib/utils/array_utility.js
+++ b/app/assets/javascripts/lib/utils/array_utility.js
@@ -18,3 +18,13 @@ export const swapArrayItems = (array, leftIndex = 0, rightIndex = 0) => {
copy[rightIndex] = temp;
return copy;
};
+
+/**
+ * Return an array with all duplicate items from the given array
+ *
+ * @param {Array} array - The source array
+ * @returns {Array} new array with all duplicate items
+ */
+export const getDuplicateItemsFromArray = (array) => [
+ ...new Set(array.filter((value, index) => array.indexOf(value) !== index)),
+];
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index cf6ce2c4889..96d019f62f2 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -130,19 +130,6 @@ export const isInViewport = (el, offset = {}) => {
);
};
-export const parseUrl = (url) => {
- const parser = document.createElement('a');
- parser.href = url;
- return parser;
-};
-
-export const parseUrlPathname = (url) => {
- const parsedUrl = parseUrl(url);
- // parsedUrl.pathname will return an absolute path for Firefox and a relative path for IE11
- // We have to make sure we always have an absolute path.
- return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : `/${parsedUrl.pathname}`;
-};
-
export const isMetaKey = (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// Identify following special clicks
diff --git a/app/assets/javascripts/lib/utils/ignore_while_pending.js b/app/assets/javascripts/lib/utils/ignore_while_pending.js
new file mode 100644
index 00000000000..e85a573c8f2
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/ignore_while_pending.js
@@ -0,0 +1,26 @@
+/**
+ * This will wrap the given function to make sure that it is only triggered once
+ * while executing asynchronously
+ *
+ * @param {Function} fn some function that returns a promise
+ * @returns A function that will only be triggered *once* while the promise is executing
+ */
+export const ignoreWhilePending = (fn) => {
+ const isPendingMap = new WeakMap();
+ const defaultContext = {};
+
+ // We need this to be a function so we get the `this`
+ return function ignoreWhilePendingInner(...args) {
+ const context = this || defaultContext;
+
+ if (isPendingMap.get(context)) {
+ return Promise.resolve();
+ }
+
+ isPendingMap.set(context, true);
+
+ return fn.apply(this, args).finally(() => {
+ isPendingMap.delete(context);
+ });
+ };
+};
diff --git a/app/assets/javascripts/lib/utils/rails_ujs.js b/app/assets/javascripts/lib/utils/rails_ujs.js
index 6b1985a23ba..b4f425da871 100644
--- a/app/assets/javascripts/lib/utils/rails_ujs.js
+++ b/app/assets/javascripts/lib/utils/rails_ujs.js
@@ -1,5 +1,6 @@
import Rails from '@rails/ujs';
import { confirmViaGlModal } from './confirm_via_gl_modal/confirm_via_gl_modal';
+import { ignoreWhilePending } from './ignore_while_pending';
function monkeyPatchConfirmModal() {
/**
@@ -18,8 +19,10 @@ function monkeyPatchConfirmModal() {
* @param element {HTMLElement} Element that was clicked on
* @returns {boolean}
*/
+ const safeConfirm = ignoreWhilePending(confirmViaGlModal);
+
function confirmViaModal(message, element) {
- confirmViaGlModal(message, element)
+ safeConfirm(message, element)
.then((confirmed) => {
if (confirmed) {
Rails.confirm = () => true;
diff --git a/app/assets/javascripts/lib/utils/resize_observer.js b/app/assets/javascripts/lib/utils/resize_observer.js
index e72c6fe1679..5d194340b9e 100644
--- a/app/assets/javascripts/lib/utils/resize_observer.js
+++ b/app/assets/javascripts/lib/utils/resize_observer.js
@@ -10,22 +10,30 @@ export function createResizeObserver() {
});
}
-// watches for change in size of a container element (e.g. for lazy-loaded images)
-// and scroll the target element to the top of the content area
-// stop watching after any user input. So if user opens sidebar or manually
-// scrolls the page we don't hijack their scroll position
+/**
+ * Watches for change in size of a container element (e.g. for lazy-loaded images)
+ * and scrolls the target note to the top of the content area.
+ * Stops watching after any user input. So if user opens sidebar or manually
+ * scrolls the page we don't hijack their scroll position
+ *
+ * @param {Object} options
+ * @param {string} options.targetId - id of element to scroll to
+ * @param {string} options.container - Selector of element containing target
+ *
+ * @return {ResizeObserver|null} - ResizeObserver instance if target looks like a note DOM ID
+ */
export function scrollToTargetOnResize({
- target = window.location.hash,
+ targetId = window.location.hash.slice(1),
container = '#content-body',
} = {}) {
- if (!target) return null;
+ if (!targetId) return null;
const ro = createResizeObserver();
const containerEl = document.querySelector(container);
let interactionListenersAdded = false;
function keepTargetAtTop() {
- const anchorEl = document.querySelector(target);
+ const anchorEl = document.getElementById(targetId);
if (!anchorEl) return;
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index ec6789d81ec..ac2eb34260c 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -9,7 +9,7 @@ const LINK_TAG_PATTERN = '[{text}](url)';
// a bullet point character (*+-) and an optional checkbox ([ ] [x])
// OR a number with a . after it and an optional checkbox ([ ] [x])
// followed by one or more whitespace characters
-const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isOl>[*+-])|(?<isUl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/;
+const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/;
function selectedText(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
@@ -31,8 +31,19 @@ function lineBefore(text, textarea, trimNewlines = true) {
return split[split.length - 1];
}
-function lineAfter(text, textarea) {
- return text.substring(textarea.selectionEnd).trim().split('\n')[0];
+function lineAfter(text, textarea, trimNewlines = true) {
+ let split = text.substring(textarea.selectionEnd);
+
+ if (trimNewlines) {
+ split = split.trim();
+ } else {
+ // remove possible leading newline to get at the real line
+ split = split.replace(/^\n/, '');
+ }
+
+ split = split.split('\n');
+
+ return split[0];
}
function convertMonacoSelectionToAceFormat(sel) {
@@ -329,6 +340,25 @@ function handleSurroundSelectedText(e, textArea) {
}
/* eslint-enable @gitlab/require-i18n-strings */
+/**
+ * Returns the content for a new line following a list item.
+ *
+ * @param {Object} result - regex match of the current line
+ * @param {Object?} nextLineResult - regex match of the next line
+ * @returns string with the new list item
+ */
+function continueOlText(result, nextLineResult) {
+ const { indent, leader } = result.groups;
+ const { indent: nextIndent, isOl: nextIsOl } = nextLineResult?.groups ?? {};
+
+ const [numStr, postfix = ''] = leader.split('.');
+
+ const incrementBy = nextIsOl && nextIndent === indent ? 0 : 1;
+ const num = parseInt(numStr, 10) + incrementBy;
+
+ return `${indent}${num}.${postfix}`;
+}
+
function handleContinueList(e, textArea) {
if (!gon.features?.markdownContinueLists) return;
if (!(e.key === 'Enter')) return;
@@ -339,7 +369,7 @@ function handleContinueList(e, textArea) {
const result = currentLine.match(LIST_LINE_HEAD_PATTERN);
if (result) {
- const { indent, content, leader } = result.groups;
+ const { leader, indent, content, isOl } = result.groups;
const prevLineEmpty = !content;
if (prevLineEmpty) {
@@ -349,12 +379,22 @@ function handleContinueList(e, textArea) {
return;
}
- const itemInsert = `${indent}${leader}`;
+ let itemToInsert;
+
+ if (isOl) {
+ const nextLine = lineAfter(textArea.value, textArea, false);
+ const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN);
+
+ itemToInsert = continueOlText(result, nextLineResult);
+ } else {
+ // isUl
+ itemToInsert = `${indent}${leader}`;
+ }
e.preventDefault();
updateText({
- tag: itemInsert,
+ tag: itemToInsert,
textArea,
blockTag: '',
wrap: false,
@@ -367,6 +407,8 @@ function handleContinueList(e, textArea) {
export function keypressNoteText(e) {
const textArea = this;
+ if ($(textArea).atwho?.('isSelecting')) return;
+
handleContinueList(e, textArea);
handleSurroundSelectedText(e, textArea);
}
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 12462a2575e..335cd6a16e5 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -18,6 +18,20 @@ function resetRegExp(regex) {
return regex;
}
+/**
+ * Returns the absolute pathname for a relative or absolute URL string.
+ *
+ * A few examples of inputs and outputs:
+ * 1) 'http://a.com/b/c/d' => '/b/c/d'
+ * 2) '/b/c/d' => '/b/c/d'
+ * 3) 'b/c/d' => '/b/c/d' or '[path]/b/c/d' depending of the current path of the
+ * document.location
+ */
+export const parseUrlPathname = (url) => {
+ const { pathname } = new URL(url, document.location.href);
+ return pathname;
+};
+
// Returns a decoded url parameter value
// - Treats '+' as '%20'
function decodeUrlParameter(val) {
diff --git a/app/assets/javascripts/loading_icon_for_legacy_js.js b/app/assets/javascripts/loading_icon_for_legacy_js.js
new file mode 100644
index 00000000000..d50a4275424
--- /dev/null
+++ b/app/assets/javascripts/loading_icon_for_legacy_js.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const defaultValue = (prop) => GlLoadingIcon.props[prop]?.default;
+
+/**
+ * Returns a loading icon/spinner element.
+ *
+ * This should *only* be used in existing legacy areas of code where Vue is not
+ * in use, as part of the migration strategy defined in
+ * https://gitlab.com/groups/gitlab-org/-/epics/7626.
+ *
+ * @param {object} props - The props to configure the spinner.
+ * @param {boolean} props.inline - Display the spinner inline; otherwise, as a block.
+ * @param {string} props.color - The color of the spinner ('dark' or 'light')
+ * @param {string} props.size - The size of the spinner ('sm', 'md', 'lg', 'xl')
+ * @param {string[]} props.classes - Additional classes to apply to the element.
+ * @param {string} props.label - The ARIA label to apply to the spinner.
+ * @returns {HTMLElement}
+ */
+export const loadingIconForLegacyJS = ({
+ inline = defaultValue('inline'),
+ color = defaultValue('color'),
+ size = defaultValue('size'),
+ classes = [],
+ label = __('Loading'),
+} = {}) => {
+ const mountEl = document.createElement('div');
+
+ const vm = new Vue({
+ el: mountEl,
+ render(h) {
+ return h(GlLoadingIcon, {
+ class: classes,
+ props: {
+ inline,
+ color,
+ size,
+ label,
+ },
+ });
+ },
+ });
+
+ // Ensure it's rendered
+ vm.$forceUpdate();
+
+ const el = vm.$el.cloneNode(true);
+ vm.$destroy();
+
+ return el;
+};
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index f78b4da181e..b3cb93e74f2 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -116,16 +116,18 @@ function deferredInitialisation() {
);
}
- const search = document.querySelector('#search');
- if (search) {
- search.addEventListener(
+ const searchInputBox = document.querySelector('#search');
+ if (searchInputBox) {
+ searchInputBox.addEventListener(
'focus',
() => {
if (gon.features?.newHeaderSearch) {
import(/* webpackChunkName: 'globalSearch' */ '~/header_search')
.then(async ({ initHeaderSearchApp }) => {
- await initHeaderSearchApp();
- document.querySelector('#search').focus();
+ // In case the user started searching before we bootstrapped, let's pass the search along.
+ const initialSearchValue = searchInputBox.value;
+ await initHeaderSearchApp(initialSearchValue);
+ searchInputBox.focus();
})
.catch(() => {});
} else {
@@ -159,6 +161,12 @@ function deferredInitialisation() {
// Adding a helper class to activate animations only after all is rendered
setTimeout(() => $body.addClass('page-initialised'), 1000);
+
+ if (window.gon?.features?.mrAttentionRequests) {
+ import('~/attention_requests')
+ .then((module) => module.default())
+ .catch(() => {});
+ }
}
const $body = $('body');
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
deleted file mode 100644
index a28427eb9ac..00000000000
--- a/app/assets/javascripts/member_expiration_date.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import $ from 'jquery';
-import Pikaday from 'pikaday';
-import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
-
-// Add datepickers to all `js-access-expiration-date` elements. If those elements are
-// children of an element with the `clearable-input` class, and have a sibling
-// `js-clear-input` element, then show that element when there is a value in the
-// datepicker, and make clicking on that element clear the field.
-//
-export default function memberExpirationDate(selector = '.js-access-expiration-date') {
- function toggleClearInput() {
- $(this)
- .closest('.clearable-input')
- .toggleClass('has-value', $(this).val() !== '');
- }
- const inputs = $(selector);
-
- inputs.each((i, el) => {
- const $input = $(el);
-
- const calendar = new Pikaday({
- field: $input.get(0),
- theme: 'gitlab-theme animate-picker',
- format: 'yyyy-mm-dd',
- minDate: new Date(),
- container: $input.parent().get(0),
- parse: (dateString) => parsePikadayDate(dateString),
- toString: (date) => pikadayToString(date),
- onSelect(dateText) {
- $input.val(calendar.toString(dateText));
-
- toggleClearInput.call($input);
- },
- firstDay: gon.first_day_of_week,
- });
-
- calendar.setDate(parsePikadayDate($input.val()));
- $input.data('pikaday', calendar);
- });
-
- inputs.next('.js-clear-input').on('click', function clicked(event) {
- event.preventDefault();
-
- const input = $(this).closest('.clearable-input').find(selector);
- const calendar = input.data('pikaday');
-
- calendar.setDate(null);
- toggleClearInput.call(input);
- });
-
- inputs.on('blur', toggleClearInput);
-
- inputs.each(toggleClearInput);
-}
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
index 01606d07554..27c67e84675 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
@@ -25,7 +25,8 @@ export default {
},
title: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
icon: {
type: String,
diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
index 594da7f68cc..122e0a142a9 100644
--- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
@@ -61,7 +61,7 @@ export default {
};
},
removeMemberButtonText() {
- return this.isInvitedUser ? null : __('Remove user');
+ return this.isInvitedUser ? null : __('Remove member');
},
removeMemberButtonIcon() {
return this.isInvitedUser ? 'remove' : '';
@@ -86,7 +86,6 @@ export default {
:icon="removeMemberButtonIcon"
:button-text="removeMemberButtonText"
:button-category="removeMemberButtonCategory"
- :title="s__('Member|Remove member')"
/>
</div>
<div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1">
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index 633dee75237..ca60f876c6f 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -1,5 +1,4 @@
<script>
-import { GlFilteredSearchToken } from '@gitlab/ui';
import { mapState } from 'vuex';
import {
getParameterByName,
@@ -7,46 +6,24 @@ import {
queryToObject,
redirectTo,
} from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
import {
SEARCH_TOKEN_TYPE,
SORT_QUERY_PARAM_NAME,
ACTIVE_TAB_QUERY_PARAM_NAME,
-} from '~/members/constants';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+ AVAILABLE_FILTERED_SEARCH_TOKENS,
+} from 'ee_else_ce/members/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
export default {
name: 'MembersFilteredSearchBar',
components: { FilteredSearchBar },
- availableTokens: [
- {
- type: 'two_factor',
- icon: 'lock',
- title: s__('Members|2FA'),
- token: GlFilteredSearchToken,
- unique: true,
- operators: OPERATOR_IS_ONLY,
- options: [
- { value: 'enabled', title: s__('Members|Enabled') },
- { value: 'disabled', title: s__('Members|Disabled') },
- ],
- requiredPermissions: 'canManageMembers',
- },
- {
- type: 'with_inherited_permissions',
- icon: 'group',
- title: s__('Members|Membership'),
- token: GlFilteredSearchToken,
- unique: true,
- operators: OPERATOR_IS_ONLY,
- options: [
- { value: 'exclude', title: s__('Members|Direct') },
- { value: 'only', title: s__('Members|Inherited') },
- ],
- },
- ],
- inject: ['namespace', 'sourceId', 'canManageMembers'],
+ availableTokens: AVAILABLE_FILTERED_SEARCH_TOKENS,
+ inject: {
+ namespace: {},
+ sourceId: {},
+ canManageMembers: {},
+ canFilterByEnterprise: { default: false },
+ },
data() {
return {
initialFilterValue: [],
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 273f1acebc7..49ce00a1689 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -1,4 +1,7 @@
-import { __ } from '~/locale';
+import { GlFilteredSearchToken } from '@gitlab/ui';
+
+import { __, s__ } from '~/locale';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
export const FIELD_KEY_ACCOUNT = 'account';
export const FIELD_KEY_SOURCE = 'source';
@@ -82,6 +85,38 @@ export const DEFAULT_SORT = {
sortDesc: false,
};
+export const FILTERED_SEARCH_TOKEN_TWO_FACTOR = {
+ type: 'two_factor',
+ icon: 'lock',
+ title: s__('Members|2FA'),
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATOR_IS_ONLY,
+ options: [
+ { value: 'enabled', title: s__('Members|Enabled') },
+ { value: 'disabled', title: s__('Members|Disabled') },
+ ],
+ requiredPermissions: 'canManageMembers',
+};
+
+export const FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS = {
+ type: 'with_inherited_permissions',
+ icon: 'group',
+ title: s__('Members|Membership'),
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATOR_IS_ONLY,
+ options: [
+ { value: 'exclude', title: s__('Members|Direct') },
+ { value: 'only', title: s__('Members|Inherited') },
+ ],
+};
+
+export const AVAILABLE_FILTERED_SEARCH_TOKENS = [
+ FILTERED_SEARCH_TOKEN_TWO_FACTOR,
+ FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS,
+];
+
export const AVATAR_SIZE = 48;
export const MEMBER_TYPES = {
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index 510e89240f4..0df876cabd7 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -18,6 +18,7 @@ export const initMembersApp = (el, options) => {
sourceId,
canManageMembers,
canExportMembers,
+ canFilterByEnterprise,
exportCsvPath,
...vuexStoreAttributes
} = parseDataAttributes(el);
@@ -60,6 +61,7 @@ export const initMembersApp = (el, options) => {
currentUserId: gon.current_user_id || null,
sourceId,
canManageMembers,
+ canFilterByEnterprise,
canExportMembers,
exportCsvPath,
},
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
index 5fcc778a714..fdcb99351a7 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf, GlButton, GlButtonGroup } from '@gitlab/ui';
+import { GlSprintf, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
import { mapGetters, mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -23,6 +23,7 @@ export default {
GlButton,
GlButtonGroup,
GlSprintf,
+ GlLoadingIcon,
FileIcon,
DiffFileEditor,
InlineConflictLines,
@@ -72,9 +73,7 @@ export default {
</script>
<template>
<div id="conflicts">
- <div v-if="isLoading" class="loading">
- <div class="spinner spinner-md"></div>
- </div>
+ <gl-loading-icon v-if="isLoading" size="md" data-testid="loading-spinner" />
<div v-if="hasError" class="nothing-here-block">
{{ conflictsData.errorMessage }}
</div>
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index ad0117844cd..61f7a079d77 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -2,13 +2,8 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
import Vue from 'vue';
-import {
- getCookie,
- parseUrlPathname,
- isMetaClick,
- parseBoolean,
- scrollToElement,
-} from '~/lib/utils/common_utils';
+import { getCookie, isMetaClick, parseBoolean, scrollToElement } from '~/lib/utils/common_utils';
+import { parseUrlPathname } from '~/lib/utils/url_utility';
import createEventHub from '~/helpers/event_hub_factory';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import Diff from './diff';
@@ -70,6 +65,103 @@ const FAST_DELAY_FOR_RERENDER = 75;
// Store the `location` object, allowing for easier stubbing in tests
let { location } = window;
+function scrollToContainer(container) {
+ if (location.hash) {
+ const $el = $(`${container} ${location.hash}:not(.match)`);
+
+ if ($el.length) {
+ scrollToElement($el[0]);
+ }
+ }
+}
+
+function computeTopOffset(tabs) {
+ const navbar = document.querySelector('.navbar-gitlab');
+ const peek = document.getElementById('js-peek');
+ let stickyTop;
+
+ stickyTop = navbar ? navbar.offsetHeight : 0;
+ stickyTop = peek ? stickyTop + peek.offsetHeight : stickyTop;
+ stickyTop = tabs ? stickyTop + tabs.offsetHeight : stickyTop;
+
+ return stickyTop;
+}
+
+function mountPipelines() {
+ const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
+ const { mrWidgetData } = gl;
+ const table = new Vue({
+ components: {
+ CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'),
+ },
+ provide: {
+ artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
+ artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
+ targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
+ },
+ render(createElement) {
+ return createElement('commit-pipelines-table', {
+ props: {
+ endpoint: pipelineTableViewEl.dataset.endpoint,
+ emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
+ errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
+ canCreatePipelineInTargetProject: Boolean(
+ mrWidgetData?.can_create_pipeline_in_target_project,
+ ),
+ sourceProjectFullPath: mrWidgetData?.source_project_full_path || '',
+ targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
+ projectId: pipelineTableViewEl.dataset.projectId,
+ mergeRequestId: mrWidgetData ? mrWidgetData.iid : null,
+ },
+ });
+ },
+ }).$mount();
+
+ // $mount(el) replaces the el with the new rendered component. We need it in order to mount
+ // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
+ pipelineTableViewEl.appendChild(table.$el);
+
+ return table;
+}
+
+function destroyPipelines(app) {
+ if (app && app.$destroy) {
+ app.$destroy();
+
+ document.querySelector('#commit-pipeline-table-view').innerHTML = '';
+ }
+
+ return null;
+}
+
+function loadDiffs({ url, sticky }) {
+ return axios.get(`${url}.json${location.search}`).then(({ data }) => {
+ const $container = $('#diffs');
+ $container.html(data.html);
+ initDiffStatsDropdown(sticky);
+
+ localTimeAgo(document.querySelectorAll('#diffs .js-timeago'));
+ syntaxHighlight($('#diffs .js-syntax-highlight'));
+
+ new Diff();
+ scrollToContainer('#diffs');
+
+ $('.diff-file').each((i, el) => {
+ new BlobForkSuggestion({
+ openButtons: $(el).find('.js-edit-blob-link-fork-toggler'),
+ forkButtons: $(el).find('.js-fork-suggestion-button'),
+ cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'),
+ suggestionSections: $(el).find('.js-file-fork-suggestion-section'),
+ actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'),
+ }).init();
+ });
+ });
+}
+
+function toggleLoader(state) {
+ $('.mr-loading-status .loading').toggleClass('hide', !state);
+}
+
export default class MergeRequestTabs {
constructor({ action, setUrl, stubLocation } = {}) {
this.mergeRequestTabs = document.querySelector('.merge-request-tabs-container');
@@ -107,13 +199,7 @@ export default class MergeRequestTabs {
}
this.bindEvents();
- if (
- this.mergeRequestTabs &&
- this.mergeRequestTabs.querySelector(`a[data-action='${action}']`) &&
- this.mergeRequestTabs.querySelector(`a[data-action='${action}']`).click
- ) {
- this.mergeRequestTabs.querySelector(`a[data-action='${action}']`).click();
- }
+ this.mergeRequestTabs?.querySelector(`a[data-action='${action}']`)?.click?.();
}
bindEvents() {
@@ -132,15 +218,6 @@ export default class MergeRequestTabs {
$('.merge-request-tabs a[data-toggle="tabvue"]').off('click', this.clickTab);
}
- destroyPipelinesView() {
- if (this.commitPipelinesTable) {
- this.commitPipelinesTable.$destroy();
- this.commitPipelinesTable = null;
-
- document.querySelector('#commit-pipeline-table-view').innerHTML = '';
- }
- }
-
storeScroll() {
if (this.currentTab) {
this.scrollPositions[this.currentTab] = document.documentElement.scrollTop;
@@ -207,11 +284,11 @@ export default class MergeRequestTabs {
this.loadCommits(href);
this.expandView();
this.resetViewContainer();
- this.destroyPipelinesView();
+ this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable);
} else if (action === 'new') {
this.expandView();
this.resetViewContainer();
- this.destroyPipelinesView();
+ this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable);
} else if (this.isDiffAction(action)) {
if (!isInVueNoteablePage()) {
/*
@@ -228,7 +305,7 @@ export default class MergeRequestTabs {
this.shrinkView();
}
this.expandViewContainer();
- this.destroyPipelinesView();
+ this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable);
this.commitsTab.classList.remove('active');
} else if (action === 'pipelines') {
this.resetViewContainer();
@@ -247,7 +324,7 @@ export default class MergeRequestTabs {
this.expandView();
}
this.resetViewContainer();
- this.destroyPipelinesView();
+ this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable);
}
$('.detail-page-description').renderGFM();
@@ -280,16 +357,6 @@ export default class MergeRequestTabs {
this.eventHub.$emit('MergeRequestTabChange', action);
}
- scrollToContainerElement(container) {
- if (location.hash) {
- const $el = $(`${container} ${location.hash}:not(.match)`);
-
- if ($el.length) {
- scrollToElement($el[0]);
- }
- }
- }
-
// Replaces the current merge request-specific action in the URL with a new one
//
// If the action is "notes", the URL is reset to the standard
@@ -356,7 +423,7 @@ export default class MergeRequestTabs {
return;
}
- this.toggleLoading(true);
+ toggleLoader(true);
axios
.get(`${source}.json`)
@@ -365,15 +432,15 @@ export default class MergeRequestTabs {
commitsDiv.innerHTML = data.html;
localTimeAgo(commitsDiv.querySelectorAll('.js-timeago'));
this.commitsLoaded = true;
- this.scrollToContainerElement('#commits');
+ scrollToContainer('#commits');
- this.toggleLoading(false);
+ toggleLoader(false);
return import('./add_context_commits_modal');
})
.then((m) => m.default())
.catch(() => {
- this.toggleLoading(false);
+ toggleLoader(false);
createFlash({
message: __('An error occurred while fetching this tab.'),
});
@@ -381,39 +448,7 @@ export default class MergeRequestTabs {
}
mountPipelinesView() {
- const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
- const { mrWidgetData } = gl;
-
- this.commitPipelinesTable = new Vue({
- components: {
- CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'),
- },
- provide: {
- artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
- artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
- targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
- },
- render(createElement) {
- return createElement('commit-pipelines-table', {
- props: {
- endpoint: pipelineTableViewEl.dataset.endpoint,
- emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
- errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
- canCreatePipelineInTargetProject: Boolean(
- mrWidgetData?.can_create_pipeline_in_target_project,
- ),
- sourceProjectFullPath: mrWidgetData?.source_project_full_path || '',
- targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
- projectId: pipelineTableViewEl.dataset.projectId,
- mergeRequestId: mrWidgetData ? mrWidgetData.iid : null,
- },
- });
- },
- }).$mount();
-
- // $mount(el) replaces the el with the new rendered component. We need it in order to mount
- // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
- pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
+ this.commitPipelinesTable = mountPipelines();
}
// load the diff tab content from the backend
@@ -423,57 +458,31 @@ export default class MergeRequestTabs {
return;
}
- // We extract pathname for the current Changes tab anchor href
- // some pages like MergeRequestsController#new has query parameters on that anchor
- const urlPathname = parseUrlPathname(source);
-
- this.toggleLoading(true);
-
- axios
- .get(`${urlPathname}.json${location.search}`)
- .then(({ data }) => {
- const $container = $('#diffs');
- $container.html(data.html);
- initDiffStatsDropdown(this.stickyTop);
-
- localTimeAgo(document.querySelectorAll('#diffs .js-timeago'));
- syntaxHighlight($('#diffs .js-syntax-highlight'));
+ toggleLoader(true);
+ loadDiffs({
+ // We extract pathname for the current Changes tab anchor href
+ // some pages like MergeRequestsController#new has query parameters on that anchor
+ url: parseUrlPathname(source),
+ sticky: computeTopOffset(this.mergeRequestTabs),
+ })
+ .then(() => {
if (this.isDiffAction(this.currentAction)) {
this.expandViewContainer();
}
- this.diffsLoaded = true;
- new Diff();
- this.scrollToContainerElement('#diffs');
-
- $('.diff-file').each((i, el) => {
- new BlobForkSuggestion({
- openButtons: $(el).find('.js-edit-blob-link-fork-toggler'),
- forkButtons: $(el).find('.js-fork-suggestion-button'),
- cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'),
- suggestionSections: $(el).find('.js-file-fork-suggestion-section'),
- actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'),
- }).init();
- });
-
- this.toggleLoading(false);
+ this.diffsLoaded = true;
})
.catch(() => {
- this.toggleLoading(false);
createFlash({
message: __('An error occurred while fetching this tab.'),
});
+ })
+ .finally(() => {
+ toggleLoader(false);
});
}
- // Show or hide the loading spinner
- //
- // status - Boolean, true to show, false to hide
- toggleLoading(status) {
- $('.mr-loading-status .loading').toggleClass('hide', !status);
- }
-
diffViewType() {
return $('.js-diff-view-buttons button.active').data('viewType');
}
@@ -529,18 +538,4 @@ export default class MergeRequestTabs {
}
}, 0);
}
-
- get stickyTop() {
- let stickyTop = this.navbar ? this.navbar.offsetHeight : 0;
-
- if (this.peek) {
- stickyTop += this.peek.offsetHeight;
- }
-
- if (this.mergeRequestTabs) {
- stickyTop += this.mergeRequestTabs.offsetHeight;
- }
-
- return stickyTop;
- }
}
diff --git a/app/assets/javascripts/monitoring/components/charts/bar.vue b/app/assets/javascripts/monitoring/components/charts/bar.vue
index a4cef5ea256..1e0f4b10297 100644
--- a/app/assets/javascripts/monitoring/components/charts/bar.vue
+++ b/app/assets/javascripts/monitoring/components/charts/bar.vue
@@ -1,5 +1,4 @@
<script>
-import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlBarChart } from '@gitlab/ui/dist/charts';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { chartHeight } from '../../constants';
@@ -9,9 +8,6 @@ export default {
components: {
GlBarChart,
},
- directives: {
- GlResizeObserverDirective,
- },
props: {
graphData: {
type: Object,
@@ -60,11 +56,6 @@ export default {
formatLegendLabel(query) {
return query.label;
},
- onResize() {
- if (!this.$refs.barChart) return;
- const { width } = this.$refs.barChart.$el.getBoundingClientRect();
- this.width = width;
- },
setSvg(name) {
getSvgIconPathContent(name)
.then((path) => {
@@ -81,17 +72,16 @@ export default {
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <gl-bar-chart
- ref="barChart"
- v-bind="$attrs"
- :data="chartData"
- :option="chartOptions"
- :width="width"
- :height="height"
- :x-axis-title="xAxisTitle"
- :y-axis-title="yAxisTitle"
- :x-axis-type="xAxisType"
- />
- </div>
+ <gl-bar-chart
+ ref="barChart"
+ v-bind="$attrs"
+ :responsive="true"
+ :data="chartData"
+ :option="chartOptions"
+ :width="width"
+ :height="height"
+ :x-axis-title="xAxisTitle"
+ :y-axis-title="yAxisTitle"
+ :x-axis-type="xAxisType"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue
index 37251af2049..e8f54b1fa34 100644
--- a/app/assets/javascripts/monitoring/components/charts/column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/column.vue
@@ -1,5 +1,4 @@
<script>
-import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import { makeDataSeries } from '~/helpers/monitor_helper';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
@@ -12,9 +11,6 @@ export default {
components: {
GlColumnChart,
},
- directives: {
- GlResizeObserverDirective,
- },
props: {
graphData: {
type: Object,
@@ -83,11 +79,6 @@ export default {
formatLegendLabel(query) {
return query.label;
},
- onResize() {
- if (!this.$refs.columnChart) return;
- const { width } = this.$refs.columnChart.$el.getBoundingClientRect();
- this.width = width;
- },
setSvg(name) {
getSvgIconPathContent(name)
.then((path) => {
@@ -101,17 +92,16 @@ export default {
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <gl-column-chart
- ref="columnChart"
- v-bind="$attrs"
- :bars="barChartData"
- :option="chartOptions"
- :width="width"
- :height="height"
- :x-axis-title="xAxisTitle"
- :y-axis-title="yAxisTitle"
- :x-axis-type="xAxisType"
- />
- </div>
+ <gl-column-chart
+ ref="columnChart"
+ v-bind="$attrs"
+ :responsive="true"
+ :bars="barChartData"
+ :option="chartOptions"
+ :width="width"
+ :height="height"
+ :x-axis-title="xAxisTitle"
+ :y-axis-title="yAxisTitle"
+ :x-axis-type="xAxisType"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/gauge.vue b/app/assets/javascripts/monitoring/components/charts/gauge.vue
index 461ff06be72..0477ff19ffe 100644
--- a/app/assets/javascripts/monitoring/components/charts/gauge.vue
+++ b/app/assets/javascripts/monitoring/components/charts/gauge.vue
@@ -1,5 +1,4 @@
<script>
-import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlGaugeChart } from '@gitlab/ui/dist/charts';
import { isFinite, isArray, isInteger } from 'lodash';
import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
@@ -10,9 +9,6 @@ export default {
components: {
GlGaugeChart,
},
- directives: {
- GlResizeObserverDirective,
- },
props: {
graphData: {
type: Object,
@@ -96,27 +92,19 @@ export default {
return this.queryResult || NaN;
},
},
- methods: {
- onResize() {
- if (!this.$refs.gaugeChart) return;
- const { width } = this.$refs.gaugeChart.$el.getBoundingClientRect();
- this.width = width;
- },
- },
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <gl-gauge-chart
- ref="gaugeChart"
- v-bind="$attrs"
- :value="value"
- :min="rangeValues.min"
- :max="rangeValues.max"
- :thresholds="thresholdsValue"
- :text="textValue"
- :split-number="splitValue"
- :width="width"
- />
- </div>
+ <gl-gauge-chart
+ ref="gaugeChart"
+ v-bind="$attrs"
+ :responsive="true"
+ :value="value"
+ :min="rangeValues.min"
+ :max="rangeValues.max"
+ :thresholds="thresholdsValue"
+ :text="textValue"
+ :split-number="splitValue"
+ :width="width"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
index ed888ef022c..12add274a90 100644
--- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue
+++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
@@ -1,5 +1,4 @@
<script>
-import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlHeatmap } from '@gitlab/ui/dist/charts';
import { formatDate, timezones, formats } from '../../format_date';
import { graphDataValidatorForValues } from '../../utils';
@@ -8,9 +7,6 @@ export default {
components: {
GlHeatmap,
},
- directives: {
- GlResizeObserverDirective,
- },
props: {
graphData: {
type: Object,
@@ -61,26 +57,18 @@ export default {
return this.graphData.metrics[0];
},
},
- methods: {
- onResize() {
- if (this.$refs.heatmapChart) return;
- const { width } = this.$refs.heatmapChart.$el.getBoundingClientRect();
- this.width = width;
- },
- },
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <gl-heatmap
- ref="heatmapChart"
- v-bind="$attrs"
- :data-series="chartData"
- :x-axis-name="xAxisName"
- :y-axis-name="yAxisName"
- :x-axis-labels="xAxisLabels"
- :y-axis-labels="yAxisLabels"
- :width="width"
- />
- </div>
+ <gl-heatmap
+ ref="heatmapChart"
+ v-bind="$attrs"
+ :responsive="true"
+ :data-series="chartData"
+ :x-axis-name="xAxisName"
+ :y-axis-name="yAxisName"
+ :x-axis-labels="xAxisLabels"
+ :y-axis-labels="yAxisLabels"
+ :width="width"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
index a53f899f752..0cf39448d6b 100644
--- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
@@ -1,5 +1,4 @@
<script>
-import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { s__ } from '~/locale';
@@ -12,9 +11,6 @@ export default {
components: {
GlStackedColumnChart,
},
- directives: {
- GlResizeObserverDirective,
- },
props: {
graphData: {
type: Object,
@@ -125,32 +121,26 @@ export default {
console.error('SVG could not be rendered correctly: ', e);
});
},
- onResize() {
- if (!this.$refs.chart) return;
- const { width } = this.$refs.chart.$el.getBoundingClientRect();
- this.width = width;
- },
},
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <gl-stacked-column-chart
- ref="chart"
- v-bind="$attrs"
- :bars="chartData"
- :option="chartOptions"
- :x-axis-title="xAxisTitle"
- :y-axis-title="yAxisTitle"
- :x-axis-type="xAxisType"
- :group-by="groupBy"
- :width="width"
- :height="height"
- :legend-layout="legendLayout"
- :legend-average-text="legendAverageText"
- :legend-current-text="legendCurrentText"
- :legend-max-text="legendMaxText"
- :legend-min-text="legendMinText"
- />
- </div>
+ <gl-stacked-column-chart
+ ref="chart"
+ v-bind="$attrs"
+ :responsive="true"
+ :bars="chartData"
+ :option="chartOptions"
+ :x-axis-title="xAxisTitle"
+ :y-axis-title="yAxisTitle"
+ :x-axis-type="xAxisType"
+ :group-by="groupBy"
+ :width="width"
+ :height="height"
+ :legend-layout="legendLayout"
+ :legend-average-text="legendAverageText"
+ :legend-current-text="legendCurrentText"
+ :legend-max-text="legendMaxText"
+ :legend-min-text="legendMinText"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 5529a94874b..a95b143920b 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlTooltip, GlResizeObserverDirective, GlIcon } from '@gitlab/ui';
+import { GlLink, GlTooltip, GlIcon } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { isEmpty, omit, throttle } from 'lodash';
import { makeDataSeries } from '~/helpers/monitor_helper';
@@ -28,9 +28,6 @@ export default {
GlLink,
GlIcon,
},
- directives: {
- GlResizeObserverDirective,
- },
inheritAttrs: false,
props: {
graphData: {
@@ -366,64 +363,58 @@ export default {
eChart.off('datazoom');
eChart.on('datazoom', this.throttledDatazoom);
},
- onResize() {
- if (!this.$refs.chart) return;
- const { width } = this.$refs.chart.$el.getBoundingClientRect();
- this.width = width;
- },
},
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <component
- :is="glChartComponent"
- ref="chart"
- v-bind="$attrs"
- :group-id="groupId"
- :data="chartData"
- :option="chartOptions"
- :format-tooltip-text="formatTooltipText"
- :format-annotations-tooltip-text="formatAnnotationsTooltipText"
- :width="width"
- :height="height"
- :legend-layout="legendLayout"
- :legend-average-text="legendAverageText"
- :legend-current-text="legendCurrentText"
- :legend-max-text="legendMaxText"
- :legend-min-text="legendMinText"
- @created="onChartCreated"
- @updated="onChartUpdated"
- >
- <template #tooltip-title>
- <template v-if="tooltip.type === 'deployments'">
- {{ __('Deployed') }}
- </template>
- <div v-else class="text-nowrap">
- {{ tooltip.title }}
- </div>
+ <component
+ :is="glChartComponent"
+ ref="chart"
+ v-bind="$attrs"
+ :responsive="true"
+ :group-id="groupId"
+ :data="chartData"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ :format-annotations-tooltip-text="formatAnnotationsTooltipText"
+ :width="width"
+ :height="height"
+ :legend-layout="legendLayout"
+ :legend-average-text="legendAverageText"
+ :legend-current-text="legendCurrentText"
+ :legend-max-text="legendMaxText"
+ :legend-min-text="legendMinText"
+ @created="onChartCreated"
+ @updated="onChartUpdated"
+ >
+ <template #tooltip-title>
+ <template v-if="tooltip.type === 'deployments'">
+ {{ __('Deployed') }}
</template>
- <template #tooltip-content>
- <div v-if="tooltip.type === 'deployments'" class="d-flex align-items-center">
- <gl-icon name="commit" class="mr-2" />
- <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
- </div>
- <template v-else>
- <div
- v-for="(content, key) in tooltip.content"
- :key="key"
- class="d-flex justify-content-between"
- >
- <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
- {{ content.name }}
- </gl-chart-series-label>
- <div class="gl-ml-7">
- {{ content.value }}
- </div>
+ <div v-else class="text-nowrap">
+ {{ tooltip.title }}
+ </div>
+ </template>
+ <template #tooltip-content>
+ <div v-if="tooltip.type === 'deployments'" class="d-flex align-items-center">
+ <gl-icon name="commit" class="mr-2" />
+ <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
+ </div>
+ <template v-else>
+ <div
+ v-for="(content, key) in tooltip.content"
+ :key="key"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="gl-ml-7">
+ {{ content.value }}
</div>
- </template>
+ </div>
</template>
- </component>
- </div>
+ </template>
+ </component>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 102afaf308f..d5a7fc36ace 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -116,7 +116,7 @@ export default {
<gl-dropdown
v-if="displayFilters"
id="discussion-filter-dropdown"
- class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container"
+ class="full-width-mobile discussion-filter-container js-discussion-filter-container"
data-qa-selector="discussion_filter_dropdown"
:text="currentFilter.title"
:disabled="isLoading"
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 0925195d4bb..71d767c3b95 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -6,6 +6,7 @@ import {
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { mapActions } from 'vuex';
+import { __ } from '~/locale';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserNameWithStatus from '../../sidebar/components/assignees/user_name_with_status.vue';
@@ -139,6 +140,10 @@ export default {
return selectedAuthor?.availability || '';
},
},
+ i18n: {
+ showThread: __('Show thread'),
+ hideThread: __('Hide thread'),
+ },
};
</script>
@@ -148,10 +153,16 @@ export default {
<button
class="note-action-button discussion-toggle-button js-vue-toggle-button"
type="button"
+ data-testid="thread-toggle"
@click="handleToggle"
>
<gl-icon ref="chevronIcon" :name="toggleChevronIconName" />
- {{ __('Toggle thread') }}
+ <template v-if="expanded">
+ {{ $options.i18n.hideThread }}
+ </template>
+ <template v-else>
+ {{ $options.i18n.showThread }}
+ </template>
</button>
</div>
<template v-if="hasAuthor">
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index ddf72587ba3..c4602363da1 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -6,6 +6,7 @@ import createFlash from '~/flash';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { s__, __ } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
@@ -171,7 +172,7 @@ export default {
this.expandDiscussion({ discussionId: this.discussion.id });
}
},
- async cancelReplyForm(shouldConfirm, isDirty) {
+ cancelReplyForm: ignoreWhilePending(async function cancelReplyForm(shouldConfirm, isDirty) {
if (shouldConfirm && isDirty) {
const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
@@ -188,7 +189,7 @@ export default {
this.isReplying = false;
clearDraft(this.autosaveKey);
- },
+ }),
saveReply(noteText, form, callback) {
if (!noteText) {
this.cancelReplyForm();
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 7bad10616cc..a271ac91f6e 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -7,6 +7,7 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_m
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
+import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { __, s__, sprintf } from '../../locale';
@@ -350,7 +351,10 @@ export default {
parent: this.$el,
});
},
- async formCancelHandler({ shouldConfirm, isDirty }) {
+ formCancelHandler: ignoreWhilePending(async function formCancelHandler({
+ shouldConfirm,
+ isDirty,
+ }) {
if (shouldConfirm && isDirty) {
const msg = __('Are you sure you want to cancel editing this comment?');
const confirmed = await confirmAction(msg);
@@ -364,7 +368,7 @@ export default {
}
this.isEditing = false;
this.$emit('cancelForm');
- },
+ }),
recoverNoteContent(noteText) {
// we need to do this to prevent noteForm inconsistent content warning
// this is something we intentionally do so we need to recover the content
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
index 56d2ff86fb7..1b7d5af6134 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
@@ -1,7 +1,11 @@
<script>
import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui';
-import { ALERT_MESSAGES, ADMIN_GARBAGE_COLLECTION_TIP } from '../../constants/index';
+import {
+ ALERT_MESSAGES,
+ ADMIN_GARBAGE_COLLECTION_TIP,
+ ALERT_DANGER_IMPORTING,
+} from '../../constants/index';
export default {
components: {
@@ -23,6 +27,7 @@ export default {
},
},
garbageCollectionHelpPagePath: { type: String, required: false, default: '' },
+ containerRegistryImportingHelpPagePath: { type: String, required: false, default: '' },
isAdmin: {
type: Boolean,
default: false,
@@ -48,6 +53,11 @@ export default {
}
return config;
},
+ alertHref() {
+ return this.deleteAlertType === ALERT_DANGER_IMPORTING
+ ? this.containerRegistryImportingHelpPagePath
+ : this.garbageCollectionHelpPagePath;
+ },
},
};
</script>
@@ -61,7 +71,7 @@ export default {
>
<gl-sprintf :message="deleteAlertConfig.message">
<template #docLink="{ content }">
- <gl-link :href="garbageCollectionHelpPagePath" target="_blank">
+ <gl-link :href="alertHref" target="_blank">
{{ content }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
index 29c181f04fb..ab0418388cd 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
@@ -4,6 +4,7 @@ import { sprintf, n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
import {
UPDATED_AT,
CLEANUP_UNSCHEDULED_TEXT,
@@ -23,7 +24,7 @@ import {
ROOT_IMAGE_TOOLTIP,
} from '../../constants/index';
-import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_container_repository_tags_count.query.graphql';
+import getContainerRepositoryMetadata from '../../graphql/queries/get_container_repository_metadata.query.graphql';
export default {
name: 'DetailsHeader',
@@ -50,7 +51,7 @@ export default {
},
apollo: {
containerRepository: {
- query: getContainerRepositoryTagsCountQuery,
+ query: getContainerRepositoryMetadata,
variables() {
return {
id: this.image.id,
@@ -101,6 +102,10 @@ export default {
imageName() {
return this.imageDetails.name || ROOT_IMAGE_TEXT;
},
+ formattedSize() {
+ const { size } = this.imageDetails;
+ return size ? numberToHumanSize(Number(size)) : null;
+ },
},
};
</script>
@@ -119,10 +124,15 @@ export default {
:aria-label="rootImageTooltip"
/>
</template>
+
<template #metadata-tags-count>
<metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
</template>
+ <template v-if="formattedSize" #metadata-size>
+ <metadata-item icon="disk" :text="formattedSize" data-testid="image-size" />
+ </template>
+
<template #metadata-cleanup>
<metadata-item
icon="expire"
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
index 8b8769a884d..3c7f7ca9aa8 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
@@ -93,6 +93,10 @@ export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while scheduling the image for deletion.',
);
+export const DETAILS_IMPORTING_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Tags temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}.',
+);
+
export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?');
export const DELETE_IMAGE_CONFIRMATION_TEXT = s__(
'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}',
@@ -133,6 +137,7 @@ export const ALERT_DANGER_TAG = 'danger_tag';
export const ALERT_SUCCESS_TAGS = 'success_tags';
export const ALERT_DANGER_TAGS = 'danger_tags';
export const ALERT_DANGER_IMAGE = 'danger_image';
+export const ALERT_DANGER_IMPORTING = 'danger_importing';
export const DELETE_SCHEDULED = 'DELETE_SCHEDULED';
export const DELETE_FAILED = 'DELETE_FAILED';
@@ -143,6 +148,7 @@ export const ALERT_MESSAGES = {
[ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE,
[ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE,
[ALERT_DANGER_IMAGE]: DETAILS_DELETE_IMAGE_ERROR_MESSAGE,
+ [ALERT_DANGER_IMPORTING]: DETAILS_IMPORTING_ERROR_MESSAGE,
};
export const UNFINISHED_STATUS = 'UNFINISHED';
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql
index 9092a71edb0..f1f67b98407 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql
@@ -1,6 +1,7 @@
-query getContainerRepositoryTagsCount($id: ID!) {
+query getContainerRepositoryMetadata($id: ID!) {
containerRepository(id: $id) {
id
tagsCount
+ size
}
}
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
index 931849c9918..71a85d8885e 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
@@ -20,6 +20,7 @@ import {
ALERT_SUCCESS_TAGS,
ALERT_DANGER_TAGS,
ALERT_DANGER_IMAGE,
+ ALERT_DANGER_IMPORTING,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
@@ -32,6 +33,8 @@ import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_c
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql';
+const REPOSITORY_IMPORTING_ERROR_MESSAGE = 'repository importing';
+
export default {
name: 'RegistryDetailsPage',
components: {
@@ -147,12 +150,17 @@ export default {
});
if (data?.destroyContainerRepositoryTags?.errors[0]) {
- throw new Error();
+ throw new Error(data.destroyContainerRepositoryTags.errors[0]);
}
this.deleteAlertType =
itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS;
} catch (e) {
- this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS;
+ if (e.message === REPOSITORY_IMPORTING_ERROR_MESSAGE) {
+ this.deleteAlertType = ALERT_DANGER_IMPORTING;
+ } else {
+ this.deleteAlertType =
+ itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS;
+ }
}
this.mutationLoading = false;
@@ -188,6 +196,7 @@ export default {
<delete-alert
v-model="deleteAlertType"
:garbage-collection-help-page-path="config.garbageCollectionHelpPagePath"
+ :container-registry-importing-help-page-path="config.containerRegistryImportingHelpPagePath"
:is-admin="config.isAdmin"
class="gl-my-2"
/>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
index e2acebf39d6..5f9e614bebb 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
@@ -13,9 +13,8 @@ import getContainerRepositoriesQuery from 'shared_queries/container_registry/get
import createFlash from '~/flash';
import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
-import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import Tracking from '~/tracking';
-import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import DeleteImage from '../components/delete_image.vue';
import RegistryHeader from '../components/list_page/registry_header.vue';
@@ -61,8 +60,8 @@ export default {
GlSkeletonLoader,
RegistryHeader,
DeleteImage,
- RegistrySearch,
CleanupPolicyEnabledAlert,
+ PersistedSearch,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -130,8 +129,7 @@ export default {
containerRepositoriesCount: 0,
itemToDelete: {},
deleteAlertType: null,
- filter: [],
- sorting: { orderBy: 'UPDATED', sort: 'desc' },
+ sorting: null,
name: null,
mutationLoading: false,
fetchBaseQuery: false,
@@ -154,7 +152,7 @@ export default {
queryVariables() {
return {
name: this.name,
- sort: this.sortBy,
+ sort: this.sorting,
fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
isGroupPage: this.config.isGroupPage,
first: GRAPHQL_PAGE_SIZE,
@@ -182,24 +180,6 @@ export default {
? DELETE_IMAGE_SUCCESS_MESSAGE
: DELETE_IMAGE_ERROR_MESSAGE;
},
- sortBy() {
- const { orderBy, sort } = this.sorting;
- return `${orderBy}_${sort}`.toUpperCase();
- },
- },
- mounted() {
- const { sorting, filters } = extractFilterAndSorting(this.$route.query);
-
- this.filter = [...filters];
- this.name = filters[0]?.value.data;
- this.sorting = { ...this.sorting, ...sorting };
-
- // If the two graphql calls - which are not batched - resolve togheter we will have a race
- // condition when apollo sets the cache, with this we give the 'base' call an headstart
- this.fetchBaseQuery = true;
- setTimeout(() => {
- this.fetchAdditionalDetails = true;
- }, 200);
},
methods: {
deleteImage(item) {
@@ -258,18 +238,20 @@ export default {
this.track('confirm_delete');
this.mutationLoading = true;
},
- updateSorting(value) {
- this.sorting = {
- ...this.sorting,
- ...value,
- };
- },
- doFilter() {
- const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM);
+ handleSearchUpdate({ sort, filters }) {
+ this.sorting = sort;
+
+ const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM);
this.name = search?.value?.data;
- },
- updateUrlQueryString(query) {
- this.$router.push({ query });
+
+ if (!this.fetchBaseQuery && !this.fetchAdditionalDetails) {
+ // If the two graphql calls - which are not batched - resolve together we will have a race
+ // condition when apollo sets the cache, with this we give the 'base' call an headstart
+ this.fetchBaseQuery = true;
+ setTimeout(() => {
+ this.fetchAdditionalDetails = true;
+ }, 200);
+ }
},
},
};
@@ -332,16 +314,12 @@ export default {
/>
</template>
</registry-header>
-
- <registry-search
- :filter="filter"
- :sorting="sorting"
- :tokens="[]"
+ <persisted-search
+ class="gl-mb-5"
:sortable-fields="$options.searchConfig"
- @sorting:changed="updateSorting"
- @filter:changed="filter = $event"
- @filter:submit="doFilter"
- @query:changed="updateUrlQueryString"
+ :default-order="$options.searchConfig[0].orderBy"
+ default-sort="desc"
+ @update="handleSearchUpdate"
/>
<div v-if="isLoading" class="gl-mt-5">
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
index 6030af9d2c3..ae2d5f4fbc5 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
@@ -13,7 +13,6 @@ import {
REMOVE_INFO_TEXT,
EXPIRATION_SCHEDULE_LABEL,
NAME_REGEX_LABEL,
- NAME_REGEX_PLACEHOLDER,
NAME_REGEX_DESCRIPTION,
CADENCE_LABEL,
EXPIRATION_POLICY_FOOTER_NOTE,
@@ -68,7 +67,6 @@ export default {
REMOVE_INFO_TEXT,
EXPIRATION_SCHEDULE_LABEL,
NAME_REGEX_LABEL,
- NAME_REGEX_PLACEHOLDER,
NAME_REGEX_DESCRIPTION,
CADENCE_LABEL,
EXPIRATION_POLICY_FOOTER_NOTE,
@@ -141,6 +139,17 @@ export default {
[model]: state,
};
},
+ encapsulateError(path, message) {
+ return {
+ graphQLErrors: [
+ {
+ extensions: {
+ problems: [{ path: [path], message }],
+ },
+ },
+ ],
+ };
+ },
submit() {
this.track('submit_form');
this.apiErrors = {};
@@ -156,7 +165,8 @@ export default {
.then(({ data }) => {
const errorMessage = data?.updateContainerExpirationPolicy?.errors[0];
if (errorMessage) {
- this.$toast.show(errorMessage);
+ const customError = this.encapsulateError('nameRegex', errorMessage);
+ throw customError;
} else {
this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE);
}
@@ -273,7 +283,6 @@ export default {
:error="apiErrors.nameRegex"
:disabled="isFieldDisabled"
:label="$options.i18n.NAME_REGEX_LABEL"
- :placeholder="$options.i18n.NAME_REGEX_PLACEHOLDER"
:description="$options.i18n.NAME_REGEX_DESCRIPTION"
name="remove-regex"
data-testid="remove-regex-input"
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 4d477fbd05d..841585c5646 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -32,7 +32,6 @@ export const REMOVE_INFO_TEXT = s__(
);
export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Remove tags older than:');
export const NAME_REGEX_LABEL = s__('ContainerRegistry|Remove tags matching:');
-export const NAME_REGEX_PLACEHOLDER = '.*';
export const NAME_REGEX_DESCRIPTION = s__(
'ContainerRegistry|Tags with names that match this regex pattern are removed. %{linkStart}View regex examples.%{linkEnd}',
);
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js
index c4b2af13862..5e0be3834cb 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js
@@ -10,6 +10,7 @@ export const updateContainerExpirationPolicy = (projectPath) => (client, { data:
const data = produce(sourceData, (draftState) => {
draftState.project.containerExpirationPolicy = {
+ ...draftState.project.containerExpirationPolicy,
...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy,
};
});
diff --git a/app/assets/javascripts/pages/admin/applications/index.js b/app/assets/javascripts/pages/admin/applications/index.js
new file mode 100644
index 00000000000..3397b02aeba
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/applications/index.js
@@ -0,0 +1,3 @@
+import initApplicationDeleteButtons from '~/admin/applications';
+
+initApplicationDeleteButtons();
diff --git a/app/assets/javascripts/pages/admin/clusters/new/index.js b/app/assets/javascripts/pages/admin/clusters/connect/index.js
index de9ded87ef3..de9ded87ef3 100644
--- a/app/assets/javascripts/pages/admin/clusters/new/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/connect/index.js
diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js
index c4e05bbd092..f5e6d044865 100644
--- a/app/assets/javascripts/pages/admin/topics/edit/index.js
+++ b/app/assets/javascripts/pages/admin/topics/edit/index.js
@@ -2,7 +2,10 @@ import $ from 'jquery';
import GLForm from '~/gl_form';
import initFilePickers from '~/file_pickers';
import ZenMode from '~/zen_mode';
+import initRemoveAvatar from '~/admin/topics';
new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
initFilePickers();
new ZenMode(); // eslint-disable-line no-new
+
+initRemoveAvatar();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index cabb1b24ae6..c4bbbdcd8ec 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -96,6 +96,8 @@ export default class Todos {
target.setAttribute('disabled', true);
target.classList.add('disabled');
+ target.querySelector('.gl-spinner-container').classList.add('gl-mr-2');
+
axios[target.dataset.method](target.dataset.href)
.then(({ data }) => {
this.updateRowState(target);
@@ -118,6 +120,8 @@ export default class Todos {
target.removeAttribute('disabled');
target.classList.remove('disabled');
+ target.querySelector('.gl-spinner-container').classList.remove('gl-mr-2');
+
if (isInactive === true) {
restoreBtn.classList.add('hidden');
doneBtn.classList.remove('hidden');
@@ -140,6 +144,8 @@ export default class Todos {
target.setAttribute('disabled', true);
target.classList.add('disabled');
+ target.querySelector('.gl-spinner-container').classList.add('gl-mr-2');
+
axios[target.dataset.method](target.dataset.href, {
ids: this.todo_ids,
})
@@ -163,6 +169,8 @@ export default class Todos {
target.removeAttribute('disabled');
target.classList.remove('disabled');
+ target.querySelector('.gl-spinner-container').classList.remove('gl-mr-2');
+
this.todo_ids = target === markAllDoneBtn ? data.updated_ids : [];
undoAllBtn.classList.toggle('hidden');
markAllDoneBtn.classList.toggle('hidden');
diff --git a/app/assets/javascripts/pages/groups/clusters/new/index.js b/app/assets/javascripts/pages/groups/clusters/connect/index.js
index de9ded87ef3..de9ded87ef3 100644
--- a/app/assets/javascripts/pages/groups/clusters/new/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/connect/index.js
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 14ce3f775b1..280b544af3c 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -1,16 +1,12 @@
import { groupMemberRequestFormatter } from '~/groups/members/utils';
-import groupsSelect from '~/groups_select';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
-import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
-import memberExpirationDate from '~/member_expiration_date';
import { initMembersApp } from '~/members';
import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
-import UsersSelect from '~/users_select';
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
@@ -22,7 +18,7 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), {
requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: {
show: true,
- tokens: ['two_factor', 'with_inherited_permissions'],
+ tokens: ['two_factor', 'with_inherited_permissions', 'enterprise'],
searchParam: 'search',
placeholder: s__('Members|Filter members'),
recentSearchesStorageKey: 'group_members',
@@ -53,16 +49,7 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), {
},
});
-groupsSelect();
-memberExpirationDate();
-memberExpirationDate('.js-access-expiration-date-groups');
initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
-
-// This is only used when `invite_members_group_modal` feature flag is disabled.
-// This can be removed when `invite_members_group_modal` feature flag is removed.
-initInviteMembersForm();
-
-new UsersSelect(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js b/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js
new file mode 100644
index 00000000000..3fe238dcb35
--- /dev/null
+++ b/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js
@@ -0,0 +1,28 @@
+function getOriginURL() {
+ const origin = new URL(window.opener.location);
+ origin.hash = '';
+ origin.search = '';
+
+ return origin;
+}
+
+function postMessageToJiraConnectApp(data) {
+ window.opener.postMessage(data, getOriginURL().toString());
+}
+
+function initOAuthCallbacks() {
+ const params = new URLSearchParams(window.location.search);
+ if (params.has('code') && params.has('state')) {
+ postMessageToJiraConnectApp({
+ success: true,
+ code: params.get('code'),
+ state: params.get('state'),
+ });
+ } else {
+ postMessageToJiraConnectApp({ success: false });
+ }
+
+ window.close();
+}
+
+initOAuthCallbacks();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 2fc9a111405..740fdb8a96a 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import TableOfContents from '~/blob/components/table_contents.vue';
@@ -11,7 +12,9 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import '~/sourcegraph/load';
+import createStore from '~/code_navigation/store';
+Vue.use(Vuex);
Vue.use(VueApollo);
Vue.use(VueRouter);
@@ -29,6 +32,7 @@ if (viewBlobEl) {
// eslint-disable-next-line no-new
new Vue({
el: viewBlobEl,
+ store: createStore(),
router,
apolloProvider,
provide: {
@@ -78,7 +82,7 @@ GpgBadges.fetch();
const codeNavEl = document.getElementById('js-code-navigation');
-if (codeNavEl) {
+if (codeNavEl && !viewBlobEl) {
const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset;
// eslint-disable-next-line promise/catch-or-return
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index d279c4cbb08..f3530b46845 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -1,12 +1,9 @@
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
-import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
import BranchSortDropdown from '~/branches/branch_sort_dropdown';
import initDiverganceGraph from '~/branches/divergence_graph';
import initDeleteBranchButton from '~/branches/init_delete_branch_button';
import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
-AjaxLoadingSpinner.init();
-
const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
'.js-branch-list',
).dataset;
diff --git a/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js b/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js
new file mode 100644
index 00000000000..61486606665
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js
@@ -0,0 +1,3 @@
+import { initCiSecureFiles } from '~/ci_secure_files';
+
+initCiSecureFiles();
diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/connect/index.js
index de9ded87ef3..de9ded87ef3 100644
--- a/app/assets/javascripts/pages/projects/clusters/new/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/connect/index.js
diff --git a/app/assets/javascripts/pages/projects/environments/index/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js
index f0554d64ddc..8e0d9ee0eab 100644
--- a/app/assets/javascripts/pages/projects/environments/index/index.js
+++ b/app/assets/javascripts/pages/projects/environments/index/index.js
@@ -1,11 +1,5 @@
-import initEnvironments from '~/environments/';
-import initNewEnvironments from '~/environments/new_index';
+import initEnvironments from '~/environments/index';
-let el = document.getElementById('environments-list-view');
+const el = document.getElementById('environments-table');
-if (el) {
- initEnvironments(el);
-} else {
- el = document.getElementById('environments-table');
- initNewEnvironments(el);
-}
+initEnvironments(el);
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/app.vue b/app/assets/javascripts/pages/projects/forks/new/components/app.vue
index 7fb41c6e7b7..0995a2118b1 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/app.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/app.vue
@@ -10,38 +10,6 @@ export default {
type: String,
required: true,
},
- endpoint: {
- type: String,
- required: true,
- },
- projectFullPath: {
- type: String,
- required: true,
- },
- projectId: {
- type: String,
- required: true,
- },
- projectName: {
- type: String,
- required: true,
- },
- projectPath: {
- type: String,
- required: true,
- },
- projectDescription: {
- type: String,
- required: true,
- },
- projectVisibility: {
- type: String,
- required: true,
- },
- restrictedVisibilityLevels: {
- type: Array,
- required: true,
- },
},
};
</script>
@@ -62,16 +30,7 @@ export default {
</p>
</div>
<div class="col-lg-9">
- <fork-form
- :endpoint="endpoint"
- :project-full-path="projectFullPath"
- :project-id="projectId"
- :project-name="projectName"
- :project-path="projectPath"
- :project-description="projectDescription"
- :project-visibility="projectVisibility"
- :restricted-visibility-levels="restrictedVisibilityLevels"
- />
+ <fork-form />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 25b62e6c971..701bf0c1e1d 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -72,40 +72,29 @@ export default {
visibilityHelpPath: {
default: '',
},
- },
- props: {
endpoint: {
- type: String,
- required: true,
+ default: '',
},
projectFullPath: {
- type: String,
- required: true,
+ default: '',
},
projectId: {
- type: String,
- required: true,
+ default: '',
},
projectName: {
- type: String,
- required: true,
+ default: '',
},
projectPath: {
- type: String,
- required: true,
+ default: '',
},
projectDescription: {
- type: String,
- required: false,
default: '',
},
projectVisibility: {
- type: String,
- required: true,
+ default: '',
},
restrictedVisibilityLevels: {
- type: Array,
- required: true,
+ default: [],
},
},
data() {
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue
deleted file mode 100644
index 10753de6cd0..00000000000
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue
+++ /dev/null
@@ -1,93 +0,0 @@
-<script>
-import { GlTabs, GlTab, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
-import createFlash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import ForkGroupsListItem from './fork_groups_list_item.vue';
-
-export default {
- components: {
- GlTabs,
- GlTab,
- GlLoadingIcon,
- GlSearchBoxByType,
- ForkGroupsListItem,
- },
- props: {
- endpoint: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- namespaces: null,
- filter: '',
- };
- },
- computed: {
- filteredNamespaces() {
- return this.namespaces.filter((n) =>
- n.name.toLowerCase().includes(this.filter.toLowerCase()),
- );
- },
- },
-
- mounted() {
- this.loadGroups();
- },
-
- methods: {
- loadGroups() {
- axios
- .get(this.endpoint)
- .then((response) => {
- this.namespaces = response.data.namespaces;
- })
- .catch(() =>
- createFlash({
- message: __('There was a problem fetching groups.'),
- }),
- );
- },
- },
-
- i18n: {
- searchPlaceholder: __('Search by name'),
- },
-};
-</script>
-<template>
- <gl-tabs class="fork-groups">
- <gl-tab :title="__('Groups and subgroups')">
- <gl-loading-icon v-if="!namespaces" size="md" class="gl-mt-3" />
- <template v-else-if="namespaces.length === 0">
- <div class="gl-text-center">
- <div class="h5">{{ __('No available groups to fork the project.') }}</div>
- <p class="gl-mt-5">
- {{ __('You must have permission to create a project in a group before forking.') }}
- </p>
- </div>
- </template>
- <div v-else-if="filteredNamespaces.length === 0" class="gl-text-center gl-mt-3">
- {{ s__('GroupsTree|No groups matched your search') }}
- </div>
- <ul v-else class="groups-list group-list-tree">
- <fork-groups-list-item
- v-for="(namespace, index) in filteredNamespaces"
- :key="index"
- :group="namespace"
- />
- </ul>
- </gl-tab>
- <template #tabs-end>
- <gl-search-box-by-type
- v-if="namespaces && namespaces.length"
- v-model="filter"
- :placeholder="$options.i18n.searchPlaceholder"
- class="gl-align-self-center gl-ml-auto fork-filtered-search"
- data-qa-selector="fork_groups_list_search_field"
- />
- </template>
- </gl-tabs>
-</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue
deleted file mode 100644
index d41488acf46..00000000000
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<script>
-import {
- GlLink,
- GlButton,
- GlIcon,
- GlAvatar,
- GlTooltipDirective,
- GlTooltip,
- GlBadge,
- GlSafeHtmlDirective as SafeHtml,
-} from '@gitlab/ui';
-import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/groups/constants';
-import csrf from '~/lib/utils/csrf';
-import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
-
-export default {
- components: {
- GlIcon,
- GlAvatar,
- GlBadge,
- GlButton,
- GlTooltip,
- GlLink,
- UserAccessRoleBadge,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- SafeHtml,
- },
- props: {
- group: {
- type: Object,
- required: true,
- },
- },
- data() {
- return { namespaces: null, isForking: false };
- },
-
- computed: {
- rowClass() {
- return {
- 'has-description': this.group.description,
- 'being-removed': this.isGroupPendingRemoval,
- };
- },
- isGroupPendingRemoval() {
- return this.group.marked_for_deletion;
- },
- hasForkedProject() {
- return Boolean(this.group.forked_project_path);
- },
- visibilityIcon() {
- return VISIBILITY_TYPE_ICON[this.group.visibility];
- },
- visibilityTooltip() {
- return GROUP_VISIBILITY_TYPE[this.group.visibility];
- },
- isSelectButtonDisabled() {
- return !this.group.can_create_project;
- },
- },
-
- methods: {
- fork() {
- this.isForking = true;
- this.$refs.form.submit();
- },
- },
-
- csrf,
-};
-</script>
-<template>
- <li :class="rowClass" class="group-row">
- <div class="group-row-contents gl-display-flex gl-align-items-center gl-py-3 gl-pr-5">
- <div
- class="folder-toggle-wrap gl-mr-3 gl-display-flex gl-align-items-center gl-text-gray-500"
- >
- <gl-icon name="folder-o" />
- </div>
- <gl-link
- :href="group.relative_path"
- class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3"
- >
- <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatarUrl" />
- </gl-link>
- <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center">
- <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1">
- <div class="title gl-display-flex gl-align-items-center gl-flex-wrap gl-mr-3">
- <gl-link :href="group.relative_path" class="gl-mt-3 gl-mr-3 gl-text-gray-900!">
- {{ group.full_name }}
- </gl-link>
- <gl-icon
- v-gl-tooltip.hover.bottom
- class="gl-display-inline-flex gl-mt-3 gl-mr-3 gl-text-gray-500"
- :name="visibilityIcon"
- :title="visibilityTooltip"
- />
- <gl-badge
- v-if="isGroupPendingRemoval"
- variant="warning"
- class="gl-display-none gl-sm-display-flex gl-mt-3 gl-mr-1"
- >{{ __('pending deletion') }}</gl-badge
- >
- <user-access-role-badge v-if="group.permission" class="gl-mt-3">
- {{ group.permission }}
- </user-access-role-badge>
- </div>
- <div v-if="group.description" class="description gl-line-height-20">
- <span v-safe-html="group.markdown_description"> </span>
- </div>
- </div>
- <div class="gl-display-flex gl-flex-shrink-0">
- <gl-button
- v-if="hasForkedProject"
- class="gl-h-7 gl-text-decoration-none!"
- :href="group.forked_project_path"
- >{{ __('Go to fork') }}</gl-button
- >
- <template v-else>
- <div ref="selectButtonWrapper">
- <form ref="form" method="POST" :action="group.fork_path">
- <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
- <gl-button
- type="submit"
- class="gl-h-7"
- :data-qa-name="group.full_name"
- category="secondary"
- variant="success"
- :disabled="isSelectButtonDisabled"
- :loading="isForking"
- @click="fork"
- >{{ __('Select') }}</gl-button
- >
- </form>
- </div>
- <gl-tooltip v-if="isSelectButtonDisabled" :target="() => $refs.selectButtonWrapper">
- {{
- __('You must have permission to create a project in a namespace before forking.')
- }}
- </gl-tooltip>
- </template>
- </div>
- </div>
- </div>
- </li>
-</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js
index 1a171252048..cbf74f755e7 100644
--- a/app/assets/javascripts/pages/projects/forks/new/index.js
+++ b/app/assets/javascripts/pages/projects/forks/new/index.js
@@ -1,61 +1,42 @@
import Vue from 'vue';
import App from './components/app.vue';
-import ForkGroupsList from './components/fork_groups_list.vue';
const mountElement = document.getElementById('fork-groups-mount-element');
-if (gon.features.forkProjectForm) {
- const {
- forkIllustration,
- endpoint,
+const {
+ forkIllustration,
+ endpoint,
+ newGroupPath,
+ projectFullPath,
+ visibilityHelpPath,
+ projectId,
+ projectName,
+ projectPath,
+ projectDescription,
+ projectVisibility,
+ restrictedVisibilityLevels,
+} = mountElement.dataset;
+
+// eslint-disable-next-line no-new
+new Vue({
+ el: mountElement,
+ provide: {
newGroupPath,
- projectFullPath,
visibilityHelpPath,
+ endpoint,
+ projectFullPath,
projectId,
projectName,
projectPath,
projectDescription,
projectVisibility,
- restrictedVisibilityLevels,
- } = mountElement.dataset;
-
- // eslint-disable-next-line no-new
- new Vue({
- el: mountElement,
- provide: {
- newGroupPath,
- visibilityHelpPath,
- },
- render(h) {
- return h(App, {
- props: {
- forkIllustration,
- endpoint,
- newGroupPath,
- projectFullPath,
- visibilityHelpPath,
- projectId,
- projectName,
- projectPath,
- projectDescription,
- projectVisibility,
- restrictedVisibilityLevels: JSON.parse(restrictedVisibilityLevels),
- },
- });
- },
- });
-} else {
- const { endpoint } = mountElement.dataset;
-
- // eslint-disable-next-line no-new
- new Vue({
- el: mountElement,
- render(h) {
- return h(ForkGroupsList, {
- props: {
- endpoint,
- },
- });
- },
- });
-}
+ restrictedVisibilityLevels: JSON.parse(restrictedVisibilityLevels),
+ },
+ render(h) {
+ return h(App, {
+ props: {
+ forkIllustration,
+ },
+ });
+ },
+});
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
index adae97c6b6f..67962d69fa5 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
@@ -27,11 +27,6 @@ export default {
required: true,
type: Object,
},
- inviteMembers: {
- type: Boolean,
- required: false,
- default: false,
- },
project: {
required: true,
type: Object,
@@ -54,7 +49,7 @@ export default {
},
},
mounted() {
- if (this.inviteMembers && this.getCookieForInviteMembers()) {
+ if (this.getCookieForInviteMembers()) {
this.openInviteMembersModal('celebrate');
}
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
index ad6dfbf41ca..09cc0032871 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
@@ -64,15 +64,7 @@ export default {
<img :src="svg" :alt="actionLabel" />
<h6>{{ title }}</h6>
<p class="gl-font-sm gl-text-gray-700">{{ description }}</p>
- <gl-link
- :href="url"
- target="_blank"
- rel="noopener noreferrer"
- data-track-action="click_link"
- :data-track-label="actionLabel"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- >{{ actionLabel }}</gl-link
- >
+ <gl-link :href="url" target="_blank" rel="noopener noreferrer" />
</div>
</gl-card>
</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
index d0ec02bbd0c..573f996a254 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
@@ -32,7 +32,7 @@ export default {
);
},
openInNewTab() {
- return ACTION_LABELS[this.action]?.openInNewTab === true;
+ return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true;
},
},
methods: {
@@ -65,8 +65,6 @@ export default {
data-testid="uncompleted-learn-gitlab-link"
data-track-action="click_link"
:data-track-label="$options.i18n.ACTION_LABELS[action].title"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
- data-track-experiment="change_continuous_onboarding_link_urls"
>
{{ $options.i18n.ACTION_LABELS[action].title }}
</gl-link>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
index 880cf699e5e..1887c48dd1b 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -62,7 +62,6 @@ export const ACTION_LABELS = {
description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
section: 'deploy',
position: 1,
- openInNewTab: true,
},
issueCreated: {
title: s__('LearnGitLab|Create an issue'),
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 c62cab1a425..63357ea9c72 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import LearnGitlab from '../components/learn_gitlab.vue';
function initLearnGitlab() {
@@ -13,13 +13,12 @@ function initLearnGitlab() {
const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions));
const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections));
const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project));
- const { inviteMembers } = el.dataset;
return new Vue({
el,
render(createElement) {
return createElement(LearnGitlab, {
- props: { actions, sections, project, inviteMembers: parseBoolean(inviteMembers) },
+ props: { actions, sections, project },
});
},
});
diff --git a/app/assets/javascripts/pages/projects/pages_domains/form.js b/app/assets/javascripts/pages/projects/pages_domains/form.js
index 169530685ad..6836d399fa4 100644
--- a/app/assets/javascripts/pages/projects/pages_domains/form.js
+++ b/app/assets/javascripts/pages/projects/pages_domains/form.js
@@ -1,4 +1,4 @@
-import setupToggleButtons from '~/toggle_buttons';
+import { initToggle } from '~/toggles';
function updateVisibility(selector, isVisible) {
Array.from(document.querySelectorAll(selector)).forEach((el) => {
@@ -11,12 +11,12 @@ function updateVisibility(selector, isVisible) {
}
export default () => {
- const toggleContainer = document.querySelector('.js-auto-ssl-toggle-container');
+ const sslToggle = initToggle(document.querySelector('.js-enable-ssl-gl-toggle'));
+ const sslToggleInput = document.querySelector('.js-project-feature-toggle-input');
- if (toggleContainer) {
- const onToggleButtonClicked = (isAutoSslEnabled) => {
+ if (sslToggle) {
+ sslToggle.$on('change', (isAutoSslEnabled) => {
updateVisibility('.js-shown-unless-auto-ssl', !isAutoSslEnabled);
-
updateVisibility('.js-shown-if-auto-ssl', isAutoSslEnabled);
Array.from(document.querySelectorAll('.js-enabled-unless-auto-ssl')).forEach((el) => {
@@ -26,8 +26,9 @@ export default () => {
el.removeAttribute('disabled');
}
});
- };
- setupToggleButtons(toggleContainer, onToggleButtonClicked);
+ sslToggleInput.setAttribute('value', isAutoSslEnabled);
+ });
}
+ return sslToggle;
};
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js
deleted file mode 100644
index 6017cd653e4..00000000000
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-
-export default class TargetBranchDropdown {
- constructor() {
- this.$dropdown = $('.js-target-branch-dropdown');
- this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
- this.$input = $('#schedule_ref');
- this.initDefaultBranch();
- this.initDropdown();
- }
-
- initDropdown() {
- initDeprecatedJQueryDropdown(this.$dropdown, {
- data: this.formatBranchesList(),
- filterable: true,
- selectable: true,
- toggleLabel: (item) => item.name,
- search: {
- fields: ['name'],
- },
- clicked: (cfg) => this.updateInputValue(cfg),
- text: (item) => item.name,
- });
-
- this.setDropdownToggle();
- }
-
- formatBranchesList() {
- return this.$dropdown.data('data').map((val) => ({ name: val }));
- }
-
- setDropdownToggle() {
- const initialValue = this.$input.val();
-
- this.$dropdownToggle.text(initialValue);
- }
-
- initDefaultBranch() {
- const initialValue = this.$input.val();
- const defaultBranch = this.$dropdown.data('defaultBranch');
-
- if (!initialValue) {
- this.$input.val(defaultBranch);
- }
- }
-
- updateInputValue({ selectedObj, e }) {
- e.preventDefault();
-
- this.$input.val(selectedObj.name);
- gl.pipelineScheduleFieldErrors.updateFormValidityState();
- }
-}
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
index 9056c76d6ca..9c039a6be81 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -1,10 +1,12 @@
import $ from 'jquery';
import Vue from 'vue';
+import { __ } from '~/locale';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list';
import GlFieldErrors from '../../../../gl_field_errors';
import Translate from '../../../../vue_shared/translate';
import intervalPatternInput from './components/interval_pattern_input.vue';
-import TargetBranchDropdown from './components/target_branch_dropdown';
import TimezoneDropdown from './components/timezone_dropdown';
Vue.use(Translate);
@@ -30,6 +32,52 @@ function initIntervalPatternInput() {
});
}
+function getEnabledRefTypes() {
+ const refTypes = [REF_TYPE_BRANCHES];
+
+ if (gon.features.pipelineSchedulesWithTags) {
+ refTypes.push(REF_TYPE_TAGS);
+ }
+
+ return refTypes;
+}
+
+function initTargetRefDropdown() {
+ const $refField = document.getElementById('schedule_ref');
+ const el = document.querySelector('.js-target-ref-dropdown');
+ const { projectId, defaultBranch } = el.dataset;
+
+ if (!$refField.value) {
+ $refField.value = defaultBranch;
+ }
+
+ const refDropdown = new Vue({
+ el,
+ render(h) {
+ return h(RefSelector, {
+ props: {
+ enabledRefTypes: getEnabledRefTypes(),
+ projectId,
+ value: $refField.value,
+ useSymbolicRefNames: true,
+ translations: {
+ dropdownHeader: gon.features.pipelineSchedulesWithTags
+ ? __('Select target branch or tag')
+ : __('Select target branch'),
+ },
+ },
+ class: 'gl-w-full',
+ });
+ },
+ });
+
+ refDropdown.$children[0].$on('input', (newRef) => {
+ $refField.value = newRef;
+ });
+
+ return refDropdown;
+}
+
export default () => {
/* Most of the form is written in haml, but for fields with more complex behaviors,
* you should mount individual Vue components here. If at some point components need
@@ -48,9 +96,10 @@ export default () => {
gl.pipelineScheduleFieldErrors.updateFormValidityState();
},
});
- gl.targetBranchDropdown = new TargetBranchDropdown();
gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement);
+ initTargetRefDropdown();
+
setupNativeFormVariableList({
container: $('.js-ci-variable-list-section'),
formField: 'schedule',
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 26c42247cf7..2c0394dc12c 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,33 +1,20 @@
-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';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
-import memberExpirationDate from '~/member_expiration_date';
import { initMembersApp } from '~/members';
import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import { projectMemberRequestFormatter } from '~/projects/members/utils';
-import UsersSelect from '~/users_select';
-groupsSelect();
-memberExpirationDate();
-memberExpirationDate('.js-access-expiration-date-groups');
initImportAProjectModal();
initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
-// This is only used when `invite_members_group_modal` feature flag is disabled.
-// This can be removed when `invite_members_group_modal` feature flag is removed.
-initInviteMembersForm();
-
-new UsersSelect(); // eslint-disable-line no-new
-
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
[MEMBER_TYPES.user]: {
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index c28de88554a..8ef31b9b983 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -60,7 +60,7 @@ export default {
contentEditor: {
renderFailed: {
message: s__(
- 'WikiPage|An error occured while trying to render the content editor. Please try again later.',
+ 'WikiPage|An error occurred while trying to render the content editor. Please try again later.',
),
primaryAction: s__('WikiPage|Retry'),
},
@@ -495,6 +495,7 @@ export default {
:textarea-value="content"
:markdown-docs-path="pageInfo.markdownHelpPath"
:uploads-path="pageInfo.uploadsPath"
+ :enable-preview="isMarkdownFormat"
class="bordered-box"
>
<template #textarea>
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 7f4e79976bc..996e12bc105 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -7,6 +7,7 @@ import axios from '~/lib/utils/axios_utils';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
import { n__, s__, __ } from '~/locale';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
const d3 = { select };
@@ -24,12 +25,6 @@ const CONTRIB_LEGENDS = [
{ title: __('30+ contributions'), min: 30 },
];
-const LOADING_HTML = `
- <div class="text-center">
- <div class="spinner spinner-md"></div>
- </div>
-`;
-
function getSystemDate(systemUtcOffsetSeconds) {
const date = new Date();
const localUtcOffsetMinutes = 0 - date.getTimezoneOffset();
@@ -286,7 +281,9 @@ export default class ActivityCalendar {
this.currentSelectedDate.getDate(),
].join('-');
- $(this.activitiesContainer).html(LOADING_HTML);
+ $(this.activitiesContainer)
+ .empty()
+ .append(loadingIconForLegacyJS({ size: 'lg' }));
axios
.get(this.calendarActivitiesPath, {
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 1bb82e1d8e6..0640faae8b7 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlModal, GlModalDirective, GlSegmentedControl } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { sortOrders, sortOrderOptions } from '../constants';
@@ -9,8 +9,9 @@ export default {
components: {
RequestWarning,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlModal,
- GlSegmentedControl,
},
directives: {
'gl-modal': GlModalDirective,
@@ -156,13 +157,19 @@ export default {
</div>
</div>
</div>
- <gl-segmented-control
+ <gl-dropdown
v-if="displaySortOrder"
+ :text="$options.sortOrderOptions[sortOrder]"
+ right
data-testid="performance-bar-sort-order"
- :options="$options.sortOrderOptions"
- :checked="sortOrder"
- @input="changeSortOrder"
- />
+ >
+ <gl-dropdown-item
+ v-for="option in Object.keys($options.sortOrderOptions)"
+ :key="option"
+ @click="changeSortOrder(option)"
+ >{{ $options.sortOrderOptions[option] }}</gl-dropdown-item
+ >
+ </gl-dropdown>
</div>
<hr />
<table class="table gl-table">
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 710f49b833c..0f744e858f2 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -134,6 +134,7 @@ export default {
methods: {
changeCurrentRequest(newRequestId) {
this.currentRequest = newRequestId;
+ this.$emit('change-request', newRequestId);
},
flamegraphPath(mode) {
return mergeUrlParams(
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index a46ac620f48..ffc22c2113d 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -1,15 +1,5 @@
<script>
-import { GlPopover, GlSafeHtmlDirective } from '@gitlab/ui';
-import { glEmojiTag } from '~/emoji';
-import { n__ } from '~/locale';
-
export default {
- components: {
- GlPopover,
- },
- directives: {
- SafeHtml: GlSafeHtmlDirective,
- },
props: {
currentRequest: {
type: Object,
@@ -25,27 +15,11 @@ export default {
currentRequestId: this.currentRequest.id,
};
},
- computed: {
- requestsWithWarnings() {
- return this.requests.filter((request) => request.hasWarnings);
- },
- warningMessage() {
- return n__(
- '%d request with warnings',
- '%d requests with warnings',
- this.requestsWithWarnings.length,
- );
- },
- },
watch: {
currentRequestId(newRequestId) {
this.$emit('change-current-request', newRequestId);
},
},
- methods: {
- glEmojiTag,
- },
- safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
<template>
@@ -58,19 +32,7 @@ export default {
data-qa-selector="request_dropdown_option"
>
{{ request.truncatedUrl }}
- <span v-if="request.hasWarnings">(!)</span>
</option>
</select>
- <span v-if="requestsWithWarnings.length" class="gl-cursor-default">
- <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"
- :content="warningMessage"
- />
- </span>
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/constants.js b/app/assets/javascripts/performance_bar/constants.js
index 9659383edd9..09745797424 100644
--- a/app/assets/javascripts/performance_bar/constants.js
+++ b/app/assets/javascripts/performance_bar/constants.js
@@ -5,13 +5,7 @@ export const sortOrders = {
CHRONOLOGICAL: 'chronological',
};
-export const sortOrderOptions = [
- {
- value: sortOrders.DURATION,
- text: s__('PerformanceBar|Sort by duration'),
- },
- {
- value: sortOrders.CHRONOLOGICAL,
- text: s__('PerformanceBar|Sort chronologically'),
- },
-];
+export const sortOrderOptions = {
+ [sortOrders.DURATION]: s__('PerformanceBar|Sort by duration'),
+ [sortOrders.CHRONOLOGICAL]: s__('PerformanceBar|Sort chronologically'),
+};
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index eb5b50dd1ec..e7f84eacdca 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -1,5 +1,6 @@
import '../webpack';
+import { isEmpty } from 'lodash';
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
@@ -37,9 +38,10 @@ const initPerformanceBar = (el) => {
};
},
mounted() {
- PerformanceBarService.registerInterceptor(this.peekUrl, this.loadRequestDetails);
+ PerformanceBarService.registerInterceptor(this.peekUrl, this.addRequest);
- this.loadRequestDetails(this.requestId, window.location.href);
+ this.addRequest(this.requestId, window.location.href);
+ this.loadRequestDetails(this.requestId);
},
beforeDestroy() {
PerformanceBarService.removeInterceptor();
@@ -51,26 +53,32 @@ const initPerformanceBar = (el) => {
// want to trace the request.
axios.get(urlOrRequestId);
} else {
- this.loadRequestDetails(urlOrRequestId, urlOrRequestId);
+ this.addRequest(urlOrRequestId, urlOrRequestId);
}
},
- loadRequestDetails(requestId, requestUrl) {
+ addRequest(requestId, requestUrl) {
if (!this.store.canTrackRequest(requestUrl)) {
return;
}
this.store.addRequest(requestId, requestUrl);
+ },
+ loadRequestDetails(requestId) {
+ const request = this.store.findRequest(requestId);
+
+ if (request && isEmpty(request.details)) {
+ return PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
+ .then((res) => {
+ this.store.addRequestDetails(requestId, res.data);
+ if (this.requestId === requestId) this.collectFrontendPerformanceMetrics();
+ })
+ .catch(() =>
+ // eslint-disable-next-line no-console
+ console.warn(`Error getting performance bar results for ${requestId}`),
+ );
+ }
- PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
- .then((res) => {
- this.store.addRequestDetails(requestId, res.data);
-
- if (this.requestId === requestId) this.collectFrontendPerformanceMetrics();
- })
- .catch(() =>
- // eslint-disable-next-line no-console
- console.warn(`Error getting performance bar results for ${requestId}`),
- );
+ return Promise.resolve();
},
collectFrontendPerformanceMetrics() {
if (performance) {
@@ -82,7 +90,9 @@ const initPerformanceBar = (el) => {
let summary = {};
if (navigationEntries.length > 0) {
const backend = Math.round(navigationEntries[0].responseEnd);
- const firstContentfulPaint = Math.round(paintEntries[1].startTime);
+ const firstContentfulPaint = Math.round(
+ paintEntries.find((entry) => entry.name === 'first-contentful-paint')?.startTime,
+ );
const domContentLoaded = Math.round(navigationEntries[0].domContentLoadedEventEnd);
summary = {
@@ -141,6 +151,7 @@ const initPerformanceBar = (el) => {
},
on: {
'add-request': this.addRequestManually,
+ 'change-request': this.loadRequestDetails,
},
});
},
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 51a8eb5ca69..5a69960e4d9 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -12,7 +12,6 @@ export default class PerformanceBarStore {
url: requestUrl,
truncatedUrl: shortUrl,
details: {},
- hasWarnings: false,
});
}
@@ -27,7 +26,6 @@ export default class PerformanceBarStore {
const request = this.findRequest(requestId);
request.details = requestDetails.data;
- request.hasWarnings = requestDetails.has_warnings;
return request;
}
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index b003302ec8e..7c424088c8b 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -13,23 +13,25 @@ export default class PersistentUserCallout {
this.featureId = featureId;
this.groupId = groupId;
this.deferLinks = parseBoolean(deferLinks);
+ this.closeButtons = this.container.querySelectorAll('.js-close');
this.init();
}
init() {
- const closeButton = this.container.querySelector('.js-close');
const followLink = this.container.querySelector('.js-follow-link');
- if (closeButton) {
- this.handleCloseButtonCallout(closeButton);
+ if (this.closeButtons.length) {
+ this.handleCloseButtonCallout();
} else if (followLink) {
this.handleFollowLinkCallout(followLink);
}
}
- handleCloseButtonCallout(closeButton) {
- closeButton.addEventListener('click', (event) => this.dismiss(event));
+ handleCloseButtonCallout() {
+ this.closeButtons.forEach((closeButton) => {
+ closeButton.addEventListener('click', this.dismiss);
+ });
if (this.deferLinks) {
this.container.addEventListener('click', (event) => {
@@ -47,7 +49,7 @@ export default class PersistentUserCallout {
followLink.addEventListener('click', (event) => this.registerCalloutWithLink(event));
}
- dismiss(event, deferredLinkOptions = null) {
+ dismiss = (event, deferredLinkOptions = null) => {
event.preventDefault();
axios
@@ -57,6 +59,9 @@ export default class PersistentUserCallout {
})
.then(() => {
this.container.remove();
+ this.closeButtons.forEach((closeButton) => {
+ closeButton.removeEventListener('click', this.dismiss);
+ });
if (deferredLinkOptions) {
const { href, target } = deferredLinkOptions;
@@ -70,7 +75,7 @@ export default class PersistentUserCallout {
),
});
});
- }
+ };
registerCalloutWithLink(event) {
event.preventDefault();
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 337c204c36a..f6de21ec0c5 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -11,6 +11,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-eoa-bronze-plan-banner',
'.js-security-newsletter-callout',
'.js-approaching-seats-count-threshold',
+ '.js-storage-enforcement-banner',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
index ca78f194a82..8536db78dfb 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -31,6 +31,14 @@ export default {
required: false,
default: '',
},
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: true,
+ },
+ isNewCiConfigFile: {
+ type: Boolean,
+ required: true,
+ },
isSaving: {
type: Boolean,
required: false,
@@ -50,11 +58,14 @@ export default {
};
},
computed: {
+ isCommitFormFilledOut() {
+ return this.message && this.targetBranch;
+ },
isCurrentBranchTarget() {
return this.targetBranch === this.currentBranch;
},
- submitDisabled() {
- return !(this.message && this.targetBranch);
+ isSubmitDisabled() {
+ return !this.isCommitFormFilledOut || (!this.hasUnsavedChanges && !this.isNewCiConfigFile);
},
},
watch: {
@@ -125,6 +136,7 @@ export default {
v-if="!isCurrentBranchTarget"
v-model="openMergeRequest"
data-testid="new-mr-checkbox"
+ data-qa-selector="new_mr_checkbox"
class="gl-mt-3"
>
<gl-sprintf :message="$options.i18n.startMergeRequest">
@@ -143,7 +155,7 @@ export default {
category="primary"
variant="confirm"
data-qa-selector="commit_changes_button"
- :disabled="submitDisabled"
+ :disabled="isSubmitDisabled"
:loading="isSaving"
>
{{ $options.i18n.commitChanges }}
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 8ff1aea020f..4ef598d6ff3 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
@@ -37,6 +37,10 @@ export default {
required: false,
default: '',
},
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: true,
+ },
isNewCiConfigFile: {
type: Boolean,
required: false,
@@ -151,6 +155,8 @@ export default {
<commit-form
:current-branch="currentBranch"
:default-message="defaultCommitMessage"
+ :has-unsaved-changes="hasUnsavedChanges"
+ :is-new-ci-config-file="isNewCiConfigFile"
:is-saving="isSaving"
:scroll-to-commit-form="scrollToCommitForm"
v-on="$listeners"
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
index 7bc096ce2c8..9cb070a5517 100644
--- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
@@ -2,7 +2,6 @@
import { GlButton, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { experiment } from '~/experimentation/utils';
import { DRAWER_EXPANDED_KEY } from '../../constants';
import FirstPipelineCard from './cards/first_pipeline_card.vue';
import GettingStartedCard from './cards/getting_started_card.vue';
@@ -50,29 +49,8 @@ export default {
},
mounted() {
this.setTopPosition();
- this.setInitialExpandState();
},
methods: {
- setInitialExpandState() {
- let isExpanded;
-
- experiment('pipeline_editor_walkthrough', {
- control: () => {
- isExpanded = true;
- },
- candidate: () => {
- isExpanded = false;
- },
- });
-
- // We check in the local storage and if no value is defined, we want the default
- // to be true. We want to explicitly set it to true here so that the drawer
- // animates to open on load.
- const localValue = localStorage.getItem(this.$options.localDrawerKey);
- if (localValue === null) {
- this.isExpanded = isExpanded;
- }
- },
setTopPosition() {
const navbarEl = document.querySelector('.js-navbar');
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 5177cea900c..255e3cb31f1 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -3,6 +3,7 @@ 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 { SOURCE_EDITOR_DEBOUNCE } from '../../constants';
export default {
editorOptions: {
@@ -10,6 +11,7 @@ export default {
// autocomplete for keywords
quickSuggestions: true,
},
+ debounceValue: SOURCE_EDITOR_DEBOUNCE,
components: {
SourceEditor,
},
@@ -34,6 +36,7 @@ export default {
<div class="gl-border-solid gl-border-gray-100 gl-border-1 gl-border-t-none!">
<source-editor
ref="editor"
+ :debounce-value="$options.debounceValue"
:editor-options="$options.editorOptions"
:file-name="ciConfigPath"
v-bind="$attrs"
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 c75b1d4bb11..5cff93c884f 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -4,7 +4,6 @@ import { s__ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
-import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
import {
CREATE_TAB,
EDITOR_APP_STATUS_EMPTY,
@@ -66,7 +65,6 @@ export default {
GlTabs,
PipelineGraph,
TextEditor,
- GitlabExperiment,
WalkthroughPopover,
},
mixins: [glFeatureFlagsMixin()],
@@ -158,11 +156,7 @@ export default {
data-testid="editor-tab"
@click="setCurrentTab($options.tabConstants.CREATE_TAB)"
>
- <gitlab-experiment name="pipeline_editor_walkthrough">
- <template #candidate>
- <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" />
- </template>
- </gitlab-experiment>
+ <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" />
<ci-editor-header />
<text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" />
</editor-tab>
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 dcd08c9de8d..aee71999373 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
@@ -41,7 +41,12 @@ export default {
</template>
</gl-sprintf>
</p>
- <gl-button variant="confirm" class="gl-mt-3" @click="createEmptyConfigFile">
+ <gl-button
+ variant="confirm"
+ class="gl-mt-3"
+ data-qa-selector="create_new_ci_button"
+ @click="createEmptyConfigFile"
+ >
{{ $options.i18n.btnText }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index a65463d02aa..2ebc4306405 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -1,3 +1,5 @@
+import { s__ } from '~/locale';
+
// Values for CI_CONFIG_STATUS_* comes from lint graphQL
export const CI_CONFIG_STATUS_INVALID = 'INVALID';
export const CI_CONFIG_STATUS_VALID = 'VALID';
@@ -47,6 +49,7 @@ export const DRAWER_EXPANDED_KEY = 'pipeline_editor_drawer_expanded';
export const BRANCH_PAGINATION_LIMIT = 20;
export const BRANCH_SEARCH_DEBOUNCE = '500';
+export const SOURCE_EDITOR_DEBOUNCE = 500;
export const STARTER_TEMPLATE_NAME = 'Getting-Started';
@@ -61,3 +64,45 @@ 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;
+
+export const RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME = 'runners_availability_section';
+export const RUNNERS_SETTINGS_LINK_CLICKED_EVENT = 'runners_settings_link_clicked';
+export const RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT = 'runners_documentation_link_clicked';
+export const RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT = 'runners_settings_button_clicked';
+export const I18N = {
+ title: s__('Pipelines|Get started with GitLab CI/CD'),
+ runners: {
+ title: s__('Pipelines|Runners are available to run your jobs now'),
+ subtitle: s__(
+ 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners.',
+ ),
+ },
+ noRunners: {
+ title: s__('Pipelines|No runners detected'),
+ subtitle: s__(
+ 'Pipelines|A GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. Install GitLab Runner and register your own runners to get started with CI/CD.',
+ ),
+ cta: s__('Pipelines|Install GitLab Runner'),
+ },
+ learnBasics: {
+ title: s__('Pipelines|Learn the basics of pipelines and .yml files'),
+ subtitle: s__(
+ 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.',
+ ),
+ gettingStarted: {
+ title: s__('Pipelines|"Hello world" with GitLab CI'),
+ description: s__(
+ 'Pipelines|Get familiar with GitLab CI syntax by setting up a simple pipeline running a "Hello world" script to see how it runs, explore how CI/CD works.',
+ ),
+ cta: s__('Pipelines|Try test template'),
+ },
+ },
+ templates: {
+ title: s__('Pipelines|Ready to set up CI/CD for your project?'),
+ subtitle: s__(
+ "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
+ ),
+ description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'),
+ cta: s__('Pipelines|Use template'),
+ },
+};
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index 04f91cb3d1e..732fc665c9e 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -2,7 +2,6 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
import { EDITOR_APP_STATUS_LOADING } from './constants';
import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql';
@@ -14,11 +13,6 @@ import typeDefs from './graphql/typedefs.graphql';
import PipelineEditorApp from './pipeline_editor_app.vue';
export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
- // Prevent issues loading syntax validation workers
- // Fixes https://gitlab.com/gitlab-org/gitlab/-/issues/297252
- // TODO Remove when https://gitlab.com/gitlab-org/gitlab/-/issues/321656 is resolved
- resetServiceWorkersPublicPath();
-
const el = document.querySelector(selector);
if (!el) {
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index 1da50c55a68..a5436ca63cb 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -69,9 +69,10 @@ export default {
// If it's a brand new file, we don't want to fetch the content.
// Then when the user commits the first time, the query would run
// to get the initial file content, but we already have it in `lastCommitedContent`
- // so we skip the loading altogether.
- skip({ isNewCiConfigFile, lastCommittedContent }) {
- return isNewCiConfigFile || lastCommittedContent;
+ // so we skip the loading altogether. We also wait for the currentBranch
+ // to have been fetched
+ skip() {
+ return this.shouldSkipBlobContentQuery;
},
variables() {
return {
@@ -128,8 +129,8 @@ export default {
},
ciConfigData: {
query: getCiConfigData,
- skip({ currentCiFileContent }) {
- return !currentCiFileContent;
+ skip() {
+ return this.shouldSkipCiConfigQuery;
},
variables() {
return {
@@ -174,6 +175,9 @@ export default {
},
commitSha: {
query: getLatestCommitShaQuery,
+ skip({ currentBranch }) {
+ return !currentBranch;
+ },
variables() {
return {
projectPath: this.projectFullPath,
@@ -181,7 +185,7 @@ export default {
};
},
update(data) {
- const latestCommitSha = data.project?.repository?.tree?.lastCommit?.sha;
+ const latestCommitSha = data?.project?.repository?.tree?.lastCommit?.sha;
if (this.isFetchingCommitSha && latestCommitSha === this.commitSha) {
this.$apollo.queries.commitSha.startPolling(COMMIT_SHA_POLL_INTERVAL);
@@ -192,6 +196,9 @@ export default {
this.$apollo.queries.commitSha.stopPolling();
return latestCommitSha;
},
+ error() {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN);
+ },
},
currentBranch: {
query: getCurrentBranch,
@@ -234,6 +241,12 @@ export default {
isEmpty() {
return this.currentCiFileContent === '';
},
+ shouldSkipBlobContentQuery() {
+ return this.isNewCiConfigFile || this.lastCommittedContent || !this.currentBranch;
+ },
+ shouldSkipCiConfigQuery() {
+ return !this.currentCiFileContent || !this.commitSha;
+ },
},
i18n: {
resetModal: {
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index bb759477e1e..631dd8a2c00 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -131,6 +131,7 @@ export default {
:ref="$options.commitSectionRef"
:ci-file-content="ciFileContent"
:commit-sha="commitSha"
+ :has-unsaved-changes="hasUnsavedChanges"
:is-new-ci-config-file="isNewCiConfigFile"
:scroll-to-commit-form="scrollToCommitForm"
@scrolled-to-commit-form="setScrollToCommitForm(false)"
diff --git a/app/assets/javascripts/pipeline_wizard/components/commit.vue b/app/assets/javascripts/pipeline_wizard/components/commit.vue
index 518b41c66b1..e68458a494f 100644
--- a/app/assets/javascripts/pipeline_wizard/components/commit.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/commit.vue
@@ -195,7 +195,7 @@ export default {
data-testid="branch_selector_group"
label-for="branch"
>
- <ref-selector id="branch" v-model="branch" data-testid="branch" :project-id="projectPath" />
+ <ref-selector id="branch" v-model="branch" :project-id="projectPath" data-testid="branch" />
</gl-form-group>
<gl-alert
v-if="!!commitError"
@@ -206,7 +206,7 @@ export default {
>
{{ commitError }}
</gl-alert>
- <step-nav show-back-button v-bind="$props" @back="$emit('go-back')">
+ <step-nav show-back-button v-bind="$props" @back="$emit('back')">
<template #after>
<gl-button
:disabled="isCommitButtonEnabled"
diff --git a/app/assets/javascripts/pipeline_wizard/components/input.vue b/app/assets/javascripts/pipeline_wizard/components/input.vue
new file mode 100644
index 00000000000..9a0c8026648
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/input.vue
@@ -0,0 +1,99 @@
+<script>
+import { isNode, isDocument, isSeq, visit } from 'yaml';
+import { capitalize } from 'lodash';
+import TextWidget from '~/pipeline_wizard/components/widgets/text.vue';
+import ListWidget from '~/pipeline_wizard/components/widgets/list.vue';
+
+const widgets = {
+ TextWidget,
+ ListWidget,
+};
+
+function isNullOrUndefined(v) {
+ return [undefined, null].includes(v);
+}
+
+export default {
+ components: {
+ ...widgets,
+ },
+ props: {
+ template: {
+ type: Object,
+ required: true,
+ validator: (v) => isNode(v),
+ },
+ compiled: {
+ type: Object,
+ required: true,
+ validator: (v) => isDocument(v) || isNode(v),
+ },
+ target: {
+ type: String,
+ required: true,
+ validator: (v) => /^\$.*/g.test(v),
+ },
+ widget: {
+ type: String,
+ required: true,
+ validator: (v) => {
+ return Object.keys(widgets).includes(`${capitalize(v)}Widget`);
+ },
+ },
+ validate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ path() {
+ let res;
+ visit(this.template, (seqKey, node, path) => {
+ if (node && node.value === this.target) {
+ // `path` is an array of objects (all the node's parents)
+ // So this reducer will reduce it to an array of the path's keys,
+ // e.g. `[ 'foo', 'bar', '0' ]`
+ res = path.reduce((p, { key }) => (key ? [...p, `${key}`] : p), []);
+ const parent = path[path.length - 1];
+ if (isSeq(parent)) {
+ res.push(seqKey);
+ }
+ }
+ });
+ return res;
+ },
+ },
+ methods: {
+ compile(v) {
+ if (!this.path) return;
+ if (isNullOrUndefined(v)) {
+ this.compiled.deleteIn(this.path);
+ }
+ this.compiled.setIn(this.path, v);
+ },
+ onModelChange(v) {
+ this.$emit('beforeUpdate:compiled');
+ this.compile(v);
+ this.$emit('update:compiled', this.compiled);
+ this.$emit('highlight', this.path);
+ },
+ onValidationStateChange(v) {
+ this.$emit('update:valid', v);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <component
+ :is="`${widget}-widget`"
+ ref="widget"
+ :validate="validate"
+ v-bind="$attrs"
+ @input="onModelChange"
+ @update:valid="onValidationStateChange"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/step.vue b/app/assets/javascripts/pipeline_wizard/components/step.vue
new file mode 100644
index 00000000000..c6f793e4cc5
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/step.vue
@@ -0,0 +1,149 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { isNode, isDocument, parseDocument, Document } from 'yaml';
+import { merge } from '~/lib/utils/yaml';
+import { s__ } from '~/locale';
+import { logError } from '~/lib/logger';
+import InputWrapper from './input.vue';
+import StepNav from './step_nav.vue';
+
+export default {
+ name: 'PipelineWizardStep',
+ i18n: {
+ errors: {
+ cloneErrorUserMessage: s__(
+ 'PipelineWizard|There was an unexpected error trying to set up the template. The error has been logged.',
+ ),
+ },
+ },
+ components: {
+ StepNav,
+ InputWrapper,
+ GlAlert,
+ },
+ props: {
+ // As the inputs prop we expect to receive an array of instructions
+ // on how to display the input fields that will be used to obtain the
+ // user's input. Each input instruction needs a target prop, specifying
+ // the placeholder in the template that will be replaced by the user's
+ // input. The selected widget may require additional validation for the
+ // input object.
+ inputs: {
+ type: Array,
+ required: true,
+ validator: (value) =>
+ value.every((i) => {
+ return i?.target && i?.widget;
+ }),
+ },
+ template: {
+ type: null,
+ required: true,
+ validator: (v) => isNode(v),
+ },
+ hasPreviousStep: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ compiled: {
+ type: Object,
+ required: true,
+ validator: (v) => isDocument(v),
+ },
+ },
+ data() {
+ return {
+ wasCompiled: false,
+ validate: false,
+ inputValidStates: Array(this.inputs.length).fill(null),
+ error: null,
+ };
+ },
+ computed: {
+ inputValidStatesThatAreNotNull() {
+ return this.inputValidStates?.filter((s) => s !== null);
+ },
+ areAllInputValidStatesNull() {
+ return !this.inputValidStatesThatAreNotNull?.length;
+ },
+ isValid() {
+ return this.areAllInputValidStatesNull || this.inputValidStatesThatAreNotNull.every((s) => s);
+ },
+ },
+ methods: {
+ forceClone(yamlNode) {
+ try {
+ // document.clone() will only clone the root document object,
+ // but the references to the child nodes inside will be retained.
+ // So in order to ensure a full clone, we need to stringify
+ // and parse until there's a better implementation in the
+ // yaml package.
+ return parseDocument(new Document(yamlNode).toString());
+ } catch (e) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ logError('An unexpected error occurred while trying to clone a template', e);
+ this.error = this.$options.i18n.errors.cloneErrorUserMessage;
+ return null;
+ }
+ },
+ compile() {
+ if (this.wasCompiled) return;
+ // NOTE: This modifies this.compiled without triggering reactivity.
+ // this is done on purpose, see
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81412#note_862972703
+ // for more information
+ merge(this.compiled, this.forceClone(this.template));
+ this.wasCompiled = true;
+ },
+ onUpdate(c) {
+ this.$emit('update:compiled', c);
+ },
+ onPrevClick() {
+ this.$emit('back');
+ },
+ async onNextClick() {
+ this.validate = true;
+ await this.$nextTick();
+ if (this.isValid) {
+ this.$emit('next');
+ }
+ },
+ onInputValidationStateChange(inputId, value) {
+ this.$set(this.inputValidStates, inputId, value);
+ },
+ onHighlight(path) {
+ this.$emit('update:highlight', path);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="error" class="gl-mb-4" variant="danger">
+ {{ error }}
+ </gl-alert>
+ <input-wrapper
+ v-for="(input, i) in inputs"
+ :key="input.target"
+ :compiled="compiled"
+ :target="input.target"
+ :template="template"
+ :validate="validate"
+ :widget="input.widget"
+ class="gl-mb-2"
+ v-bind="input"
+ @highlight="onHighlight"
+ @update:valid="(validationState) => onInputValidationStateChange(i, validationState)"
+ @update:compiled="onUpdate"
+ @beforeUpdate:compiled.once="compile"
+ />
+ <step-nav
+ :next-button-enabled="isValid"
+ :show-back-button="hasPreviousStep"
+ show-next-button
+ @back="onPrevClick"
+ @next="onNextClick"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue
new file mode 100644
index 00000000000..a5ce56daf07
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue
@@ -0,0 +1,195 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlButton, GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+const VALIDATION_STATE = {
+ NO_VALIDATION: null,
+ INVALID: false,
+ VALID: true,
+};
+
+export const i18n = {
+ addStepButtonLabel: s__('PipelineWizardListWidget|add another step'),
+ removeStepButtonLabel: s__('PipelineWizardListWidget|remove step'),
+ invalidFeedback: s__('PipelineWizardInputValidation|This value is not valid'),
+ errors: {
+ needsAnyValueError: s__('PipelineWizardInputValidation|At least one entry is required'),
+ },
+};
+
+export default {
+ i18n,
+ name: 'ListWidget',
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInputGroup,
+ },
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ default: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ invalidFeedback: {
+ type: String,
+ required: false,
+ default: i18n.invalidFeedback,
+ },
+ id: {
+ type: String,
+ required: false,
+ default: () => uniqueId('listWidget-'),
+ },
+ pattern: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ required: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ validate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ touched: false,
+ value: this.default ? this.default.map(this.getAsValueEntry) : [this.getAsValueEntry(null)],
+ };
+ },
+ computed: {
+ sanitizedValue() {
+ // Filter out empty steps
+ return this.value.filter(({ value }) => Boolean(value)).map(({ value }) => value) || [];
+ },
+ hasAnyValue() {
+ return this.value.some(({ value }) => Boolean(value));
+ },
+ needsAnyValue() {
+ return this.required && !this.value.some(({ value }) => Boolean(value));
+ },
+ inputFieldStates() {
+ return this.value.map(this.getValidationStateForValue);
+ },
+ inputGroupState() {
+ return this.showValidationState
+ ? this.inputFieldStates.every((v) => v !== VALIDATION_STATE.INVALID)
+ : VALIDATION_STATE.NO_VALIDATION;
+ },
+ showValidationState() {
+ return this.touched || this.validate;
+ },
+ feedback() {
+ return this.needsAnyValue
+ ? this.$options.i18n.errors.needsAnyValueError
+ : this.invalidFeedback;
+ },
+ },
+ async created() {
+ if (this.default) {
+ // emit an updated default value
+ await this.$nextTick();
+ this.$emit('input', this.sanitizedValue);
+ }
+ },
+ methods: {
+ addInputField() {
+ this.value.push(this.getAsValueEntry(null));
+ },
+ getAsValueEntry(value) {
+ return {
+ id: uniqueId('listValue-'),
+ value,
+ };
+ },
+ getValidationStateForValue({ value }, fieldIndex) {
+ // If we require a value to be set, mark the first
+ // field as invalid, but not all of them.
+ if (this.needsAnyValue && fieldIndex === 0) return VALIDATION_STATE.INVALID;
+ if (!value) return VALIDATION_STATE.NO_VALIDATION;
+ return this.passesPatternValidation(value)
+ ? VALIDATION_STATE.VALID
+ : VALIDATION_STATE.INVALID;
+ },
+ passesPatternValidation(v) {
+ return !this.pattern || new RegExp(this.pattern).test(v);
+ },
+ async onValueUpdate() {
+ await this.$nextTick();
+ this.$emit('input', this.sanitizedValue);
+ },
+ onTouch() {
+ this.touched = true;
+ },
+ removeValue(index) {
+ this.value.splice(index, 1);
+ this.onValueUpdate();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mb-6">
+ <gl-form-group
+ :invalid-feedback="feedback"
+ :label="label"
+ :label-description="description"
+ :state="inputGroupState"
+ class="gl-mb-2"
+ >
+ <gl-form-input-group
+ v-for="(item, i) in value"
+ :key="item.id"
+ v-model.trim="value[i].value"
+ :placeholder="i === 0 ? placeholder : undefined"
+ :state="inputFieldStates[i]"
+ class="gl-mb-2"
+ type="text"
+ @blur="onTouch"
+ @input="onValueUpdate"
+ >
+ <template v-if="value.length > 1" #append>
+ <gl-button
+ :aria-label="$options.i18n.removeStepButtonLabel"
+ category="secondary"
+ data-testid="remove-step-button"
+ icon="remove"
+ @click="removeValue"
+ />
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+ <gl-button
+ category="tertiary"
+ data-testid="add-step-button"
+ icon="plus"
+ size="small"
+ variant="confirm"
+ @click="addInputField"
+ >
+ {{ $options.i18n.addStepButtonLabel }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
new file mode 100644
index 00000000000..b7207576ddc
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
@@ -0,0 +1,185 @@
+<script>
+import { GlProgressBar } from '@gitlab/ui';
+import { Document } from 'yaml';
+import { merge } from '~/lib/utils/yaml';
+import { __ } from '~/locale';
+import { isValidStepSeq } from '~/pipeline_wizard/validators';
+import YamlEditor from './editor.vue';
+import WizardStep from './step.vue';
+import CommitStep from './commit.vue';
+
+export const i18n = {
+ stepNofN: __('Step %{currentStep} of %{stepCount}'),
+ draft: __('Draft: %{filename}'),
+ overlayMessage: __(`Start inputting changes and we will generate a
+ YAML-file for you to add to your repository`),
+};
+
+export default {
+ name: 'PipelineWizardWrapper',
+ i18n,
+ components: {
+ GlProgressBar,
+ YamlEditor,
+ WizardStep,
+ CommitStep,
+ },
+ props: {
+ steps: {
+ type: Object,
+ required: true,
+ validator: isValidStepSeq,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ highlightPath: null,
+ currentStepIndex: 0,
+ // TODO: In order to support updating existing pipelines, the below
+ // should contain a parsed version of an existing .gitlab-ci.yml.
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/355306
+ compiled: new Document({}),
+ showPlaceholder: true,
+ pipelineBlob: null,
+ placeholder: this.getPlaceholder(),
+ };
+ },
+ computed: {
+ currentStepConfig() {
+ return this.steps.get(this.currentStepIndex);
+ },
+ currentStepInputs() {
+ return this.currentStepConfig.get('inputs').toJSON();
+ },
+ currentStepTemplate() {
+ return this.currentStepConfig.get('template', true);
+ },
+ currentStep() {
+ return this.currentStepIndex + 1;
+ },
+ stepCount() {
+ return this.steps.items.length + 1;
+ },
+ progress() {
+ return Math.ceil((this.currentStep / (this.stepCount + 1)) * 100);
+ },
+ isLastStep() {
+ return this.currentStep === this.stepCount;
+ },
+ },
+ watch: {
+ isLastStep(value) {
+ if (value) this.resetHighlight();
+ },
+ },
+ methods: {
+ resetHighlight() {
+ this.highlightPath = null;
+ },
+ onUpdate() {
+ this.showPlaceholder = false;
+ },
+ onEditorUpdate(blob) {
+ // TODO: In a later iteration, we could add a loopback allowing for
+ // changes from the editor to flow back into the model
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/355312
+ this.pipelineBlob = blob;
+ },
+ getPlaceholder() {
+ const doc = new Document({});
+ this.steps.items.forEach((tpl) => {
+ merge(doc, tpl.get('template').clone());
+ });
+ return doc;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row gl-mt-8">
+ <main class="col-md-6 gl-pr-8">
+ <header class="gl-mb-5">
+ <h3 class="text-secondary gl-mt-0" data-testid="step-count">
+ {{ sprintf($options.i18n.stepNofN, { currentStep, stepCount }) }}
+ </h3>
+ <gl-progress-bar :value="progress" variant="success" />
+ </header>
+ <section class="gl-mb-4">
+ <commit-step
+ v-if="isLastStep"
+ ref="step"
+ :default-branch="defaultBranch"
+ :file-content="pipelineBlob"
+ :filename="filename"
+ :project-path="projectPath"
+ @back="currentStepIndex--"
+ />
+ <wizard-step
+ v-else
+ :key="currentStepIndex"
+ ref="step"
+ :compiled.sync="compiled"
+ :has-next-step="currentStepIndex < steps.items.length"
+ :has-previous-step="currentStepIndex > 0"
+ :highlight.sync="highlightPath"
+ :inputs="currentStepInputs"
+ :template="currentStepTemplate"
+ @back="currentStepIndex--"
+ @next="currentStepIndex++"
+ @update:compiled="onUpdate"
+ />
+ </section>
+ </main>
+ <aside class="col-md-6 gl-pt-3">
+ <div
+ class="gl-border-1 gl-border-gray-100 gl-border-solid border-radius-default gl-bg-gray-10"
+ >
+ <h6 class="gl-p-2 gl-px-4 text-secondary" data-testid="editor-header">
+ {{ sprintf($options.i18n.draft, { filename }) }}
+ </h6>
+ <div class="gl-relative gl-overflow-hidden">
+ <yaml-editor
+ :aria-hidden="showPlaceholder"
+ :doc="showPlaceholder ? placeholder : compiled"
+ :filename="filename"
+ :highlight="highlightPath"
+ class="gl-w-full"
+ @update:yaml="onEditorUpdate"
+ />
+ <div
+ v-if="showPlaceholder"
+ class="gl-absolute gl-top-0 gl-right-0 gl-bottom-0 gl-left-0 gl-filter-blur-1"
+ data-testid="placeholder-overlay"
+ >
+ <div
+ class="gl-absolute gl-top-0 gl-right-0 gl-bottom-0 gl-left-0 bg-white gl-opacity-5 gl-z-index-2"
+ ></div>
+ <div
+ class="gl-relative gl-h-full gl-display-flex gl-align-items-center gl-justify-content-center gl-z-index-3"
+ >
+ <div class="gl-max-w-34">
+ <h4 data-testid="filename">{{ filename }}</h4>
+ <p data-testid="description">
+ {{ $options.i18n.overlayMessage }}
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </aside>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
new file mode 100644
index 00000000000..7200b4e3782
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
@@ -0,0 +1,65 @@
+<script>
+import { parseDocument } from 'yaml';
+import WizardWrapper from './components/wrapper.vue';
+
+export default {
+ name: 'PipelineWizard',
+ components: {
+ WizardWrapper,
+ },
+ props: {
+ template: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ defaultFilename: {
+ type: String,
+ required: false,
+ default: '.gitlab-ci.yml',
+ },
+ },
+ computed: {
+ parsedTemplate() {
+ return this.template ? parseDocument(this.template) : null;
+ },
+ title() {
+ return this.parsedTemplate?.get('title');
+ },
+ description() {
+ return this.parsedTemplate?.get('description');
+ },
+ filename() {
+ return this.parsedTemplate?.get('filename') || this.defaultFilename;
+ },
+ steps() {
+ return this.parsedTemplate?.get('steps');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-my-8">
+ <h2 class="gl-mb-4" data-testid="title">{{ title }}</h2>
+ <p class="text-tertiary gl-font-lg gl-max-w-80" data-testid="description">
+ {{ description }}
+ </p>
+ </div>
+ <wizard-wrapper
+ v-if="steps"
+ :default-branch="defaultBranch"
+ :filename="filename"
+ :project-path="projectPath"
+ :steps="steps"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/validators.js b/app/assets/javascripts/pipeline_wizard/validators.js
new file mode 100644
index 00000000000..57cd56b23a5
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/validators.js
@@ -0,0 +1,4 @@
+import { isSeq } from 'yaml';
+
+export const isValidStepSeq = (v) =>
+ isSeq(v) && v.items.every((s) => s.get('inputs') && s.get('template'));
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 6a4d1bb44f2..ac97c9d2743 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -174,6 +174,8 @@ export default {
});
if (errors.length > 0) {
+ this.isRetrying = false;
+
this.reportFailure(POST_FAILURE);
} else {
await this.$apollo.queries.pipeline.refetch();
@@ -182,6 +184,8 @@ export default {
}
}
} catch {
+ this.isRetrying = false;
+
this.reportFailure(POST_FAILURE);
}
},
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
index 99fb5c146ba..b45f3e4f32c 100644
--- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
@@ -60,6 +60,15 @@ export default {
iid: this.pipelineIid,
};
},
+ loading() {
+ return this.$apollo.queries.jobs.loading;
+ },
+ showSkeletonLoader() {
+ return this.firstLoad && this.loading;
+ },
+ showLoadingSpinner() {
+ return !this.firstLoad && this.loading;
+ },
},
mounted() {
eventHub.$on('jobActionPerformed', this.handleJobAction);
@@ -69,7 +78,7 @@ export default {
},
methods: {
handleJobAction() {
- this.firstLoad = true;
+ this.firstLoad = false;
this.$apollo.queries.jobs.refetch();
},
@@ -98,7 +107,7 @@ export default {
<template>
<div>
- <div v-if="$apollo.loading && firstLoad" class="gl-mt-5">
+ <div v-if="showSkeletonLoader" class="gl-mt-5">
<gl-skeleton-loader :width="1248" :height="73">
<circle cx="748.031" cy="37.7193" r="15.0307" />
<circle cx="787.241" cy="37.7193" r="15.0307" />
@@ -118,7 +127,7 @@ export default {
<jobs-table v-else :jobs="jobs" :table-fields="$options.fields" data-testid="jobs-tab-table" />
<gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs">
- <gl-loading-icon v-if="$apollo.loading" size="md" />
+ <gl-loading-icon v-if="showLoadingSpinner" size="md" />
</gl-intersection-observer>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index 1ce6654e0e9..0380ba646cc 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -1,33 +1,15 @@
<script>
-import { GlEmptyState, GlButton } from '@gitlab/ui';
-import { startCodeQualityWalkthrough, track } from '~/code_quality_walkthrough/utils';
-import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
-import { getExperimentData } from '~/experimentation/utils';
-import { helpPagePath } from '~/helpers/help_page_helper';
+import { GlEmptyState } from '@gitlab/ui';
import { s__ } from '~/locale';
import PipelinesCiTemplates from './pipelines_ci_templates.vue';
export default {
i18n: {
- title: s__('Pipelines|Build with confidence'),
- description: s__(`Pipelines|GitLab CI/CD can automatically build,
- test, and deploy your code. Let GitLab take care of time
- consuming tasks, so you can spend more time creating.`),
- aboutRunnersBtnText: s__('Pipelines|Learn about Runners'),
- installRunnersBtnText: s__('Pipelines|Install GitLab Runners'),
- codeQualityTitle: s__('Pipelines|Improve code quality with GitLab CI/CD'),
- codeQualityDescription: s__(`Pipelines|To keep your codebase simple,
- readable, and accessible to contributors, use GitLab CI/CD
- to analyze your code quality with every push to your project.`),
- codeQualityBtnText: s__('Pipelines|Add a code quality job'),
noCiDescription: s__('Pipelines|This project is not currently set up to run pipelines.'),
},
name: 'PipelinesEmptyState',
components: {
GlEmptyState,
- GlButton,
- GitlabExperiment,
PipelinesCiTemplates,
},
props: {
@@ -39,88 +21,26 @@ export default {
type: Boolean,
required: true,
},
- codeQualityPagePath: {
- type: String,
- required: false,
- default: null,
- },
ciRunnerSettingsPath: {
type: String,
required: false,
default: null,
},
- },
- computed: {
- ciHelpPagePath() {
- return helpPagePath('ci/quick_start/index.md');
- },
- isCodeQualityExperimentActive() {
- return this.canSetCi && Boolean(getExperimentData('code_quality_walkthrough'));
- },
- isCiRunnerTemplatesExperimentActive() {
- return this.canSetCi && Boolean(getExperimentData('ci_runner_templates'));
- },
- },
- mounted() {
- startCodeQualityWalkthrough();
- },
- methods: {
- trackClick() {
- track('cta_clicked');
- },
- trackCiRunnerTemplatesClick(action) {
- const tracking = new ExperimentTracking('ci_runner_templates');
- tracking.event(action);
+ anyRunnersAvailable: {
+ type: Boolean,
+ required: false,
+ default: true,
},
},
};
</script>
<template>
<div>
- <gitlab-experiment v-if="isCodeQualityExperimentActive" name="code_quality_walkthrough">
- <template #control><pipelines-ci-templates /></template>
- <template #candidate>
- <gl-empty-state
- :title="$options.i18n.codeQualityTitle"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.codeQualityDescription"
- >
- <template #actions>
- <gl-button :href="codeQualityPagePath" variant="confirm" @click="trackClick()">
- {{ $options.i18n.codeQualityBtnText }}
- </gl-button>
- </template>
- </gl-empty-state>
- </template>
- </gitlab-experiment>
- <gitlab-experiment v-else-if="isCiRunnerTemplatesExperimentActive" name="ci_runner_templates">
- <template #control><pipelines-ci-templates /></template>
- <template #candidate>
- <gl-empty-state
- :title="$options.i18n.title"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.description"
- >
- <template #actions>
- <gl-button
- :href="ciRunnerSettingsPath"
- variant="confirm"
- @click="trackCiRunnerTemplatesClick('install_runners_button_clicked')"
- >
- {{ $options.i18n.installRunnersBtnText }}
- </gl-button>
- <gl-button
- :href="ciHelpPagePath"
- variant="default"
- @click="trackCiRunnerTemplatesClick('learn_button_clicked')"
- >
- {{ $options.i18n.aboutRunnersBtnText }}
- </gl-button>
- </template>
- </gl-empty-state>
- </template>
- </gitlab-experiment>
- <pipelines-ci-templates v-else-if="canSetCi" />
+ <pipelines-ci-templates
+ v-if="canSetCi"
+ :ci-runner-settings-path="ciRunnerSettingsPath"
+ :any-runners-available="anyRunnersAvailable"
+ />
<gl-empty-state
v-else
title=""
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue
new file mode 100644
index 00000000000..40b2454b8c1
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue
@@ -0,0 +1,170 @@
+<script>
+import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { SCHEDULE_ORIGIN } from '../../constants';
+
+export default {
+ components: {
+ GlBadge,
+ GlLink,
+ GlPopover,
+ GlSprintf,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ targetProjectFullPath: {
+ default: '',
+ },
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ pipelineScheduleUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isScheduled() {
+ return this.pipeline.source === SCHEDULE_ORIGIN;
+ },
+ isInFork() {
+ return Boolean(
+ this.targetProjectFullPath &&
+ this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`,
+ );
+ },
+ autoDevopsTagId() {
+ return `pipeline-url-autodevops-${this.pipeline.id}`;
+ },
+ autoDevopsHelpPath() {
+ return helpPagePath('topics/autodevops/index.md');
+ },
+ },
+};
+</script>
+<template>
+ <div class="label-container gl-mt-1">
+ <gl-badge
+ v-if="isScheduled"
+ v-gl-tooltip
+ :href="pipelineScheduleUrl"
+ target="__blank"
+ :title="__('This pipeline was triggered by a schedule.')"
+ variant="info"
+ size="sm"
+ data-testid="pipeline-url-scheduled"
+ >{{ __('Scheduled') }}</gl-badge
+ >
+ <gl-badge
+ v-if="pipeline.flags.latest"
+ v-gl-tooltip
+ :title="__('Latest pipeline for the most recent commit on this branch')"
+ variant="success"
+ size="sm"
+ data-testid="pipeline-url-latest"
+ >{{ __('latest') }}</gl-badge
+ >
+ <gl-badge
+ v-if="pipeline.flags.merge_train_pipeline"
+ v-gl-tooltip
+ :title="
+ s__(
+ 'Pipeline|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.',
+ )
+ "
+ variant="info"
+ size="sm"
+ data-testid="pipeline-url-train"
+ >{{ s__('Pipeline|merge train') }}</gl-badge
+ >
+ <gl-badge
+ v-if="pipeline.flags.yaml_errors"
+ v-gl-tooltip
+ :title="pipeline.yaml_errors"
+ variant="danger"
+ size="sm"
+ data-testid="pipeline-url-yaml"
+ >{{ __('yaml invalid') }}</gl-badge
+ >
+ <gl-badge
+ v-if="pipeline.flags.failure_reason"
+ v-gl-tooltip
+ :title="pipeline.failure_reason"
+ variant="danger"
+ size="sm"
+ data-testid="pipeline-url-failure"
+ >{{ __('error') }}</gl-badge
+ >
+ <template v-if="pipeline.flags.auto_devops">
+ <gl-link
+ :id="autoDevopsTagId"
+ tabindex="0"
+ data-testid="pipeline-url-autodevops"
+ role="button"
+ >
+ <gl-badge variant="info" size="sm">
+ {{ __('Auto DevOps') }}
+ </gl-badge>
+ </gl-link>
+ <gl-popover :target="autoDevopsTagId" triggers="focus" placement="top">
+ <template #title>
+ <div class="gl-font-weight-normal gl-line-height-normal">
+ <gl-sprintf
+ :message="
+ __(
+ 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}',
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+ <gl-link
+ :href="autoDevopsHelpPath"
+ data-testid="pipeline-url-autodevops-link"
+ target="_blank"
+ >
+ {{ __('Learn more about Auto DevOps') }}
+ </gl-link>
+ </gl-popover>
+ </template>
+
+ <gl-badge
+ v-if="pipeline.flags.stuck"
+ variant="warning"
+ size="sm"
+ data-testid="pipeline-url-stuck"
+ >{{ __('stuck') }}</gl-badge
+ >
+ <gl-badge
+ v-if="pipeline.flags.detached_merge_request_pipeline"
+ v-gl-tooltip
+ :title="
+ s__(
+ `Pipeline|This pipeline ran on the contents of this merge request's source branch, not the target branch.`,
+ )
+ "
+ variant="info"
+ size="sm"
+ data-testid="pipeline-url-detached"
+ >{{ s__('Pipeline|merge request') }}</gl-badge
+ >
+ <gl-badge
+ v-if="isInFork"
+ v-gl-tooltip
+ :title="__('Pipeline ran in fork of project')"
+ variant="info"
+ size="sm"
+ data-testid="pipeline-url-fork"
+ >{{ __('fork') }}</gl-badge
+ >
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 7c78abae77f..1dcbd77a92d 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -1,29 +1,20 @@
<script>
-import { GlIcon, GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import { SCHEDULE_ORIGIN, ICONS } from '../../constants';
+import { ICONS } from '../../constants';
+import PipelineLabels from './pipeline_labels.vue';
export default {
components: {
GlIcon,
GlLink,
- GlPopover,
- GlSprintf,
- GlBadge,
+ PipelineLabels,
TooltipOnTruncate,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
- inject: {
- targetProjectFullPath: {
- default: '',
- },
- },
props: {
pipeline: {
type: Object,
@@ -37,27 +28,8 @@ export default {
type: String,
required: true,
},
- viewType: {
- type: String,
- required: true,
- },
},
computed: {
- isScheduled() {
- return this.pipeline.source === SCHEDULE_ORIGIN;
- },
- isInFork() {
- return Boolean(
- this.targetProjectFullPath &&
- this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`,
- );
- },
- autoDevopsTagId() {
- return `pipeline-url-autodevops-${this.pipeline.id}`;
- },
- autoDevopsHelpPath() {
- return helpPagePath('topics/autodevops/index.md');
- },
mergeRequestRef() {
return this.pipeline?.merge_request;
},
@@ -139,205 +111,66 @@ export default {
commitTitle() {
return this.pipeline?.commit?.title;
},
- hasAuthor() {
- return (
- this.commitAuthor?.avatar_url && this.commitAuthor?.path && this.commitAuthor?.username
- );
- },
- userImageAltDescription() {
- return this.commitAuthor?.username
- ? sprintf(__("%{username}'s avatar"), { username: this.commitAuthor.username })
- : null;
- },
- rearrangePipelinesTable() {
- return this.glFeatures?.rearrangePipelinesTable;
- },
},
};
</script>
<template>
<div class="pipeline-tags" data-testid="pipeline-url-table-cell">
- <template v-if="rearrangePipelinesTable">
- <div class="commit-title gl-mb-2" data-testid="commit-title-container">
- <span v-if="commitTitle" class="gl-display-flex">
- <tooltip-on-truncate :title="commitTitle" class="flex-truncate-child gl-flex-grow-1">
- <gl-link
- :href="commitUrl"
- class="commit-row-message gl-text-gray-900"
- data-testid="commit-title"
- >{{ commitTitle }}</gl-link
- >
- </tooltip-on-truncate>
- </span>
- <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span>
- </div>
- <div class="gl-mb-2">
- <gl-link
- :href="pipeline.path"
- class="gl-text-decoration-underline gl-text-blue-600!"
- data-testid="pipeline-url-link"
- data-qa-selector="pipeline_url_link"
- >
- #{{ pipeline[pipelineKey] }}
- </gl-link>
- <!--Commit row-->
- <div class="icon-container gl-display-inline-block">
- <gl-icon
- v-gl-tooltip
- :name="commitIcon"
- :title="commitIconTooltipTitle"
- data-testid="commit-icon-type"
- />
- </div>
- <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top">
+ <div class="commit-title gl-mb-2" data-testid="commit-title-container">
+ <span v-if="commitTitle" class="gl-display-flex">
+ <tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate">
<gl-link
- v-if="mergeRequestRef"
- :href="mergeRequestRef.path"
- class="ref-name"
- data-testid="merge-request-ref"
- >{{ mergeRequestRef.iid }}</gl-link
+ :href="commitUrl"
+ class="commit-row-message gl-text-gray-900"
+ data-testid="commit-title"
+ >{{ commitTitle }}</gl-link
>
- <gl-link v-else :href="refUrl" class="ref-name" data-testid="commit-ref-name">{{
- commitRef.name
- }}</gl-link>
</tooltip-on-truncate>
+ </span>
+ <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span>
+ </div>
+ <div class="gl-mb-2">
+ <gl-link
+ :href="pipeline.path"
+ class="gl-text-decoration-underline gl-text-blue-600! gl-mr-3"
+ data-testid="pipeline-url-link"
+ data-qa-selector="pipeline_url_link"
+ >
+ #{{ pipeline[pipelineKey] }}
+ </gl-link>
+ <!--Commit row-->
+ <div class="icon-container gl-display-inline-block gl-mr-1">
<gl-icon
v-gl-tooltip
- name="commit"
- class="commit-icon"
- :title="__('Commit')"
- data-testid="commit-icon"
+ :name="commitIcon"
+ :title="commitIconTooltipTitle"
+ data-testid="commit-icon-type"
/>
-
- <gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{
- commitShortSha
- }}</gl-link>
- <!--End of commit row-->
</div>
- </template>
- <gl-link
- v-if="!rearrangePipelinesTable"
- :href="pipeline.path"
- class="gl-text-decoration-underline"
- data-testid="pipeline-url-link"
- data-qa-selector="pipeline_url_link"
- >
- #{{ pipeline[pipelineKey] }}
- </gl-link>
- <div class="label-container gl-mt-1">
- <gl-badge
- v-if="isScheduled"
- v-gl-tooltip
- :href="pipelineScheduleUrl"
- target="__blank"
- :title="__('This pipeline was triggered by a schedule.')"
- variant="info"
- size="sm"
- data-testid="pipeline-url-scheduled"
- >{{ __('Scheduled') }}</gl-badge
- >
- <gl-badge
- v-if="pipeline.flags.latest"
- v-gl-tooltip
- :title="__('Latest pipeline for the most recent commit on this branch')"
- variant="success"
- size="sm"
- data-testid="pipeline-url-latest"
- >{{ __('latest') }}</gl-badge
- >
- <gl-badge
- v-if="pipeline.flags.merge_train_pipeline"
- v-gl-tooltip
- :title="__('This is a merge train pipeline')"
- variant="info"
- size="sm"
- data-testid="pipeline-url-train"
- >{{ __('train') }}</gl-badge
- >
- <gl-badge
- v-if="pipeline.flags.yaml_errors"
- v-gl-tooltip
- :title="pipeline.yaml_errors"
- variant="danger"
- size="sm"
- data-testid="pipeline-url-yaml"
- >{{ __('yaml invalid') }}</gl-badge
- >
- <gl-badge
- v-if="pipeline.flags.failure_reason"
- v-gl-tooltip
- :title="pipeline.failure_reason"
- variant="danger"
- size="sm"
- data-testid="pipeline-url-failure"
- >{{ __('error') }}</gl-badge
- >
- <template v-if="pipeline.flags.auto_devops">
+ <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top">
<gl-link
- :id="autoDevopsTagId"
- tabindex="0"
- data-testid="pipeline-url-autodevops"
- role="button"
+ v-if="mergeRequestRef"
+ :href="mergeRequestRef.path"
+ class="ref-name gl-mr-3"
+ data-testid="merge-request-ref"
+ >{{ mergeRequestRef.iid }}</gl-link
>
- <gl-badge variant="info" size="sm">
- {{ __('Auto DevOps') }}
- </gl-badge>
- </gl-link>
- <gl-popover :target="autoDevopsTagId" triggers="focus" placement="top">
- <template #title>
- <div class="gl-font-weight-normal gl-line-height-normal">
- <gl-sprintf
- :message="
- __(
- 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}',
- )
- "
- >
- <template #strong="{ content }">
- <b>{{ content }}</b>
- </template>
- </gl-sprintf>
- </div>
- </template>
- <gl-link
- :href="autoDevopsHelpPath"
- data-testid="pipeline-url-autodevops-link"
- target="_blank"
- >
- {{ __('Learn more about Auto DevOps') }}
- </gl-link>
- </gl-popover>
- </template>
-
- <gl-badge
- v-if="pipeline.flags.stuck"
- variant="warning"
- size="sm"
- data-testid="pipeline-url-stuck"
- >{{ __('stuck') }}</gl-badge
- >
- <gl-badge
- v-if="pipeline.flags.detached_merge_request_pipeline"
- v-gl-tooltip
- :title="
- __(
- 'Merge request pipelines are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for merge request pipelines.',
- )
- "
- variant="info"
- size="sm"
- data-testid="pipeline-url-detached"
- >{{ __('detached') }}</gl-badge
- >
- <gl-badge
- v-if="isInFork"
+ <gl-link v-else :href="refUrl" class="ref-name gl-mr-3" data-testid="commit-ref-name">{{
+ commitRef.name
+ }}</gl-link>
+ </tooltip-on-truncate>
+ <gl-icon
v-gl-tooltip
- :title="__('Pipeline ran in fork of project')"
- variant="info"
- size="sm"
- data-testid="pipeline-url-fork"
- >{{ __('fork') }}</gl-badge
- >
+ name="commit"
+ class="commit-icon gl-mr-1"
+ :title="__('Commit')"
+ data-testid="commit-icon"
+ />
+ <gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{
+ commitShortSha
+ }}</gl-link>
+ <!--End of commit row-->
</div>
+ <pipeline-labels :pipeline-schedule-url="pipelineScheduleUrl" :pipeline="pipeline" />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index e7ff5449331..db9dc74863d 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -98,19 +98,24 @@ export default {
type: String,
required: true,
},
+ defaultBranchName: {
+ type: String,
+ required: false,
+ default: null,
+ },
params: {
type: Object,
required: true,
},
- codeQualityPagePath: {
+ ciRunnerSettingsPath: {
type: String,
required: false,
default: null,
},
- ciRunnerSettingsPath: {
- type: String,
+ anyRunnersAvailable: {
+ type: Boolean,
required: false,
- default: null,
+ default: true,
},
},
data() {
@@ -347,6 +352,7 @@ export default {
<pipelines-filtered-search
class="gl-display-flex gl-flex-grow-1 gl-mr-4"
:project-id="projectId"
+ :default-branch-name="defaultBranchName"
:params="validatedParams"
@filterPipelines="filterPipelines"
/>
@@ -380,8 +386,8 @@ export default {
v-else-if="stateToRender === $options.stateMap.emptyState"
:empty-state-svg-path="emptyStateSvgPath"
:can-set-ci="canCreatePipeline"
- :code-quality-page-path="codeQualityPagePath"
:ci-runner-settings-path="ciRunnerSettingsPath"
+ :any-runners-available="anyRunnersAvailable"
/>
<gl-empty-state
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
index 83f6356f31a..d50229e47c4 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
@@ -1,8 +1,19 @@
<script>
-import { GlAvatar, GlButton, GlCard, GlSprintf } from '@gitlab/ui';
+import { GlAvatar, GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import { s__, sprintf } from '~/locale';
-import { STARTER_TEMPLATE_NAME } from '~/pipeline_editor/constants';
+import { sprintf } from '~/locale';
+import {
+ STARTER_TEMPLATE_NAME,
+ RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
+ RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
+ RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
+ RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
+ I18N,
+} from '~/pipeline_editor/constants';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { isExperimentVariant } from '~/experimentation/utils';
import Tracking from '~/tracking';
export default {
@@ -11,39 +22,37 @@ export default {
GlButton,
GlCard,
GlSprintf,
+ GlIcon,
+ GlLink,
+ GitlabExperiment,
},
mixins: [Tracking.mixin()],
STARTER_TEMPLATE_NAME,
- i18n: {
- cta: s__('Pipelines|Use template'),
- testTemplates: {
- title: s__('Pipelines|Use a sample CI/CD template'),
- subtitle: s__(
- 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.',
- ),
- gettingStarted: {
- title: s__('Pipelines|Get started with GitLab CI/CD'),
- description: s__(
- 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a basic 3 stage CI/CD pipeline.',
- ),
- },
+ RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
+ RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
+ RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
+ RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
+ I18N,
+ inject: ['pipelineEditorPath', 'suggestedCiTemplates'],
+ props: {
+ ciRunnerSettingsPath: {
+ type: String,
+ required: false,
+ default: null,
},
- templates: {
- title: s__('Pipelines|Use a CI/CD template'),
- subtitle: s__(
- "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
- ),
- description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'),
+ anyRunnersAvailable: {
+ type: Boolean,
+ required: false,
+ default: true,
},
},
- inject: ['pipelineEditorPath', 'suggestedCiTemplates'],
data() {
const templates = this.suggestedCiTemplates.map(({ name, logo }) => {
return {
name,
logo,
link: mergeUrlParams({ template: name }, this.pipelineEditorPath),
- description: sprintf(this.$options.i18n.templates.description, { name }),
+ description: sprintf(this.$options.I18N.templates.description, { name }),
};
});
@@ -53,39 +62,104 @@ export default {
{ template: STARTER_TEMPLATE_NAME },
this.pipelineEditorPath,
),
+ tracker: null,
};
},
+ computed: {
+ sharedRunnersHelpPagePath() {
+ return helpPagePath('ci/runners/runners_scope', { anchor: 'shared-runners' });
+ },
+ runnersAvailabilitySectionExperimentEnabled() {
+ return isExperimentVariant(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME);
+ },
+ },
+ created() {
+ this.tracker = new ExperimentTracking(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME);
+ },
methods: {
trackEvent(template) {
this.track('template_clicked', {
label: template,
});
},
+ trackExperimentEvent(action) {
+ this.tracker.event(action);
+ },
},
};
</script>
<template>
<div>
- <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.testTemplates.title }}</h2>
- <p class="gl-text-gray-800 gl-mb-6">
- <gl-sprintf :message="$options.i18n.testTemplates.subtitle">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
+ <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2>
- <div class="row gl-mb-8">
- <div class="col-12">
+ <gitlab-experiment :name="$options.RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME">
+ <template #candidate>
+ <div v-if="anyRunnersAvailable">
+ <h2 class="gl-font-base gl-text-gray-900">
+ <gl-icon name="check-circle-filled" class="gl-text-green-500 gl-mr-2" :size="12" />
+ {{ $options.I18N.runners.title }}
+ </h2>
+ <p class="gl-text-gray-800 gl-mb-6">
+ <gl-sprintf :message="$options.I18N.runners.subtitle">
+ <template #settingsLink="{ content }">
+ <gl-link
+ data-testid="settings-link"
+ :href="ciRunnerSettingsPath"
+ @click="trackExperimentEvent($options.RUNNERS_SETTINGS_LINK_CLICKED_EVENT)"
+ >{{ content }}</gl-link
+ >
+ </template>
+ <template #docsLink="{ content }">
+ <gl-link
+ data-testid="documentation-link"
+ :href="sharedRunnersHelpPagePath"
+ @click="trackExperimentEvent($options.RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT)"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+
+ <div v-else>
+ <h2 class="gl-font-base gl-text-gray-900">
+ <gl-icon name="warning-solid" class="gl-text-red-600 gl-mr-2" :size="14" />
+ {{ $options.I18N.noRunners.title }}
+ </h2>
+ <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.noRunners.subtitle }}</p>
+ <gl-button
+ data-testid="settings-button"
+ category="primary"
+ variant="confirm"
+ :href="ciRunnerSettingsPath"
+ @click="trackExperimentEvent($options.RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT)"
+ >
+ {{ $options.I18N.noRunners.cta }}
+ </gl-button>
+ </div>
+ </template>
+ </gitlab-experiment>
+
+ <template v-if="!runnersAvailabilitySectionExperimentEnabled || anyRunnersAvailable">
+ <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">
+ <gl-sprintf :message="$options.I18N.learnBasics.subtitle">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8">
<gl-card>
<div class="gl-flex-direction-row">
<div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
<div class="gl-mb-3">
- <strong class="gl-text-gray-800 gl-mb-2">{{
- $options.i18n.testTemplates.gettingStarted.title
- }}</strong>
+ <strong class="gl-text-gray-800 gl-mb-2">
+ {{ $options.I18N.learnBasics.gettingStarted.title }}
+ </strong>
</div>
- <p class="gl-font-sm">{{ $options.i18n.testTemplates.gettingStarted.description }}</p>
+ <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p>
</div>
<gl-button
@@ -95,51 +169,51 @@ export default {
data-testid="test-template-link"
@click="trackEvent($options.STARTER_TEMPLATE_NAME)"
>
- {{ $options.i18n.cta }}
+ {{ $options.I18N.learnBasics.gettingStarted.cta }}
</gl-button>
</gl-card>
</div>
- </div>
- <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.templates.title }}</h2>
- <p class="gl-text-gray-800 gl-mb-6">{{ $options.i18n.templates.subtitle }}</p>
+ <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p>
- <ul class="gl-list-style-none gl-pl-0">
- <li v-for="template in templates" :key="template.name">
- <div
- class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3"
- >
- <div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
- <gl-avatar
- :src="template.logo"
- :size="64"
- class="gl-mr-6 gl-bg-white dark-mode-override"
- shape="rect"
- :alt="template.name"
- data-testid="template-logo"
- />
- <div class="gl-flex-direction-row">
- <div class="gl-mb-3">
- <strong class="gl-text-gray-800" data-testid="template-name">{{
- template.name
- }}</strong>
+ <ul class="gl-list-style-none gl-pl-0">
+ <li v-for="template in templates" :key="template.name">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3"
+ >
+ <div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
+ <gl-avatar
+ :src="template.logo"
+ :size="48"
+ class="gl-mr-5 gl-bg-white dark-mode-override"
+ shape="rect"
+ :alt="template.name"
+ data-testid="template-logo"
+ />
+ <div class="gl-flex-direction-row">
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800" data-testid="template-name">
+ {{ template.name }}
+ </strong>
+ </div>
+ <p class="gl-mb-0 gl-font-sm" data-testid="template-description">
+ {{ template.description }}
+ </p>
</div>
- <p class="gl-mb-0 gl-font-sm" data-testid="template-description">
- {{ template.description }}
- </p>
</div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="template.link"
+ data-testid="template-link"
+ @click="trackEvent(template.name)"
+ >
+ {{ $options.I18N.templates.cta }}
+ </gl-button>
</div>
- <gl-button
- category="primary"
- variant="confirm"
- :href="template.link"
- data-testid="template-link"
- @click="trackEvent(template.name)"
- >
- {{ $options.i18n.cta }}
- </gl-button>
- </div>
- </li>
- </ul>
+ </li>
+ </ul>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue
deleted file mode 100644
index cc676883c1d..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue
+++ /dev/null
@@ -1,85 +0,0 @@
-<script>
-import { CHILD_VIEW } from '~/pipelines/constants';
-import CommitComponent from '~/vue_shared/components/commit.vue';
-
-export default {
- components: {
- CommitComponent,
- },
- props: {
- pipeline: {
- type: Object,
- required: true,
- },
- viewType: {
- type: String,
- required: true,
- },
- },
- computed: {
- commitAuthor() {
- let commitAuthorInformation;
-
- if (!this.pipeline || !this.pipeline.commit) {
- return null;
- }
-
- // 1. person who is an author of a commit might be a GitLab user
- if (this.pipeline.commit.author) {
- // 2. if person who is an author of a commit is a GitLab user
- // they can have a GitLab avatar
- if (this.pipeline.commit.author.avatar_url) {
- commitAuthorInformation = this.pipeline.commit.author;
-
- // 3. If GitLab user does not have avatar, they might have a Gravatar
- } else if (this.pipeline.commit.author_gravatar_url) {
- commitAuthorInformation = {
- ...this.pipeline.commit.author,
- avatar_url: this.pipeline.commit.author_gravatar_url,
- };
- }
- // 4. If committer is not a GitLab User, they can have a Gravatar
- } else {
- commitAuthorInformation = {
- avatar_url: this.pipeline.commit.author_gravatar_url,
- path: `mailto:${this.pipeline.commit.author_email}`,
- username: this.pipeline.commit.author_name,
- };
- }
-
- return commitAuthorInformation;
- },
- commitTag() {
- return this.pipeline?.ref?.tag;
- },
- commitRef() {
- return this.pipeline?.ref;
- },
- commitUrl() {
- return this.pipeline?.commit?.commit_path;
- },
- commitShortSha() {
- return this.pipeline?.commit?.short_id;
- },
- commitTitle() {
- return this.pipeline?.commit?.title;
- },
- isChildView() {
- return this.viewType === CHILD_VIEW;
- },
- },
-};
-</script>
-
-<template>
- <commit-component
- :tag="commitTag"
- :commit-ref="commitRef"
- :commit-url="commitUrl"
- :merge-request-ref="pipeline.merge_request"
- :short-sha="commitShortSha"
- :title="commitTitle"
- :author="commitAuthor"
- :show-ref-info="!isChildView"
- />
-</template>
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 2dfdaa0ea28..4d28545a035 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
@@ -24,6 +24,11 @@ export default {
type: String,
required: true,
},
+ defaultBranchName: {
+ type: String,
+ required: false,
+ default: null,
+ },
params: {
type: Object,
required: true,
@@ -57,6 +62,7 @@ export default {
token: PipelineBranchNameToken,
operators: OPERATOR_IS_ONLY,
projectId: this.projectId,
+ defaultBranchName: this.defaultBranchName,
disabled: this.selectedTypes.includes(this.$options.tagType),
},
{
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
index 54901c2d13f..e765a8cd86c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
@@ -1,18 +1,13 @@
<script>
-import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue';
-import { PIPELINE_STATUSES } from '~/code_quality_walkthrough/constants';
import { CHILD_VIEW } from '~/pipelines/constants';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PipelinesTimeago from './time_ago.vue';
export default {
components: {
- CodeQualityWalkthrough,
CiBadge,
PipelinesTimeago,
},
- mixins: [glFeatureFlagsMixin()],
props: {
pipeline: {
type: Object,
@@ -30,23 +25,6 @@ export default {
isChildView() {
return this.viewType === CHILD_VIEW;
},
- shouldRenderCodeQualityWalkthrough() {
- return Object.values(PIPELINE_STATUSES).includes(this.pipelineStatus.group);
- },
- codeQualityStep() {
- const prefix = [PIPELINE_STATUSES.successWithWarnings, PIPELINE_STATUSES.failed].includes(
- this.pipelineStatus.group,
- )
- ? 'failed'
- : this.pipelineStatus.group;
- return `${prefix}_pipeline`;
- },
- codeQualityBuildPath() {
- return this.pipeline?.details?.code_quality_build_path;
- },
- rearrangePipelinesTable() {
- return this.glFeatures?.rearrangePipelinesTable;
- },
},
};
</script>
@@ -54,18 +32,12 @@ export default {
<template>
<div>
<ci-badge
- id="js-code-quality-walkthrough"
class="gl-mb-3"
:status="pipelineStatus"
:show-text="!isChildView"
:icon-classes="'gl-vertical-align-middle!'"
data-qa-selector="pipeline_commit_status"
/>
- <pipelines-timeago v-if="rearrangePipelinesTable" class="gl-mt-3" :pipeline="pipeline" />
- <code-quality-walkthrough
- v-if="shouldRenderCodeQualityWalkthrough"
- :step="codeQualityStep"
- :link="codeQualityBuildPath"
- />
+ <pipelines-timeago class="gl-mt-3" :pipeline="pipeline" />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index 9919a18cb99..6f0e67e1ae0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,16 +1,13 @@
<script>
import { GlTableLite, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
import PipelineMiniGraph from './pipeline_mini_graph.vue';
import PipelineOperations from './pipeline_operations.vue';
import PipelineStopModal from './pipeline_stop_modal.vue';
import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelineUrl from './pipeline_url.vue';
-import PipelinesCommit from './pipelines_commit.vue';
import PipelinesStatusBadge from './pipelines_status_badge.vue';
-import PipelinesTimeago from './time_ago.vue';
const DEFAULT_TD_CLASS = 'gl-p-5!';
const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!';
@@ -22,19 +19,57 @@ export default {
GlTableLite,
LinkedPipelinesMiniList: () =>
import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
- PipelinesCommit,
PipelineMiniGraph,
PipelineOperations,
PipelinesStatusBadge,
PipelineStopModal,
- PipelinesTimeago,
PipelineTriggerer,
PipelineUrl,
},
+ tableFields: [
+ {
+ key: 'status',
+ label: s__('Pipeline|Status'),
+ thClass: DEFAULT_TH_CLASSES,
+ columnClass: 'gl-w-15p',
+ tdClass: DEFAULT_TD_CLASS,
+ thAttr: { 'data-testid': 'status-th' },
+ },
+ {
+ key: 'pipeline',
+ label: __('Pipeline'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: `${DEFAULT_TD_CLASS}`,
+ columnClass: 'gl-w-30p',
+ thAttr: { 'data-testid': 'pipeline-th' },
+ },
+ {
+ key: 'triggerer',
+ label: s__('Pipeline|Triggerer'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
+ columnClass: 'gl-w-10p',
+ thAttr: { 'data-testid': 'triggerer-th' },
+ },
+ {
+ key: 'stages',
+ label: s__('Pipeline|Stages'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: DEFAULT_TD_CLASS,
+ columnClass: 'gl-w-quarter',
+ thAttr: { 'data-testid': 'stages-th' },
+ },
+ {
+ key: 'actions',
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: DEFAULT_TD_CLASS,
+ columnClass: 'gl-w-15p',
+ thAttr: { 'data-testid': 'actions-th' },
+ },
+ ],
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
props: {
pipelines: {
type: Array,
@@ -67,76 +102,6 @@ export default {
cancelingPipeline: null,
};
},
- computed: {
- tableFields() {
- const fields = [
- {
- key: 'status',
- label: s__('Pipeline|Status'),
- thClass: DEFAULT_TH_CLASSES,
- columnClass: this.rearrangePipelinesTable ? 'gl-w-15p' : 'gl-w-10p',
- tdClass: DEFAULT_TD_CLASS,
- thAttr: { 'data-testid': 'status-th' },
- },
- {
- key: 'pipeline',
- label: this.rearrangePipelinesTable ? __('Pipeline') : this.pipelineKeyOption.label,
- thClass: DEFAULT_TH_CLASSES,
- tdClass: this.rearrangePipelinesTable
- ? `${DEFAULT_TD_CLASS}`
- : `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
- columnClass: this.rearrangePipelinesTable ? 'gl-w-30p' : 'gl-w-10p',
- thAttr: { 'data-testid': 'pipeline-th' },
- },
- {
- key: 'triggerer',
- label: s__('Pipeline|Triggerer'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
- columnClass: 'gl-w-10p',
- thAttr: { 'data-testid': 'triggerer-th' },
- },
- {
- key: 'commit',
- label: s__('Pipeline|Commit'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: DEFAULT_TD_CLASS,
- columnClass: 'gl-w-20p',
- thAttr: { 'data-testid': 'commit-th' },
- },
- {
- key: 'stages',
- label: s__('Pipeline|Stages'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: DEFAULT_TD_CLASS,
- columnClass: 'gl-w-quarter',
- thAttr: { 'data-testid': 'stages-th' },
- },
- {
- key: 'timeago',
- label: s__('Pipeline|Duration'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: DEFAULT_TD_CLASS,
- columnClass: this.rearrangePipelinesTable ? 'gl-w-5p' : 'gl-w-15p',
- thAttr: { 'data-testid': 'timeago-th' },
- },
- {
- key: 'actions',
- thClass: DEFAULT_TH_CLASSES,
- tdClass: DEFAULT_TD_CLASS,
- columnClass: 'gl-w-15p',
- thAttr: { 'data-testid': 'actions-th' },
- },
- ];
-
- return !this.rearrangePipelinesTable
- ? fields
- : fields.filter((field) => !['commit', 'timeago'].includes(field.key));
- },
- rearrangePipelinesTable() {
- return this.glFeatures?.rearrangePipelinesTable;
- },
- },
watch: {
pipelines() {
this.cancelingPipeline = null;
@@ -167,7 +132,7 @@ export default {
<template>
<div class="ci-table">
<gl-table-lite
- :fields="tableFields"
+ :fields="$options.tableFields"
:items="pipelines"
tbody-tr-class="commit"
:tbody-tr-attr="{ 'data-testid': 'pipeline-table-row' }"
@@ -192,7 +157,6 @@ export default {
:pipeline="item"
:pipeline-schedule-url="pipelineScheduleUrl"
:pipeline-key="pipelineKeyOption.key"
- :view-type="viewType"
/>
</template>
@@ -200,10 +164,6 @@ export default {
<pipeline-triggerer :pipeline="item" />
</template>
- <template #cell(commit)="{ item }">
- <pipelines-commit :pipeline="item" :view-type="viewType" />
- </template>
-
<template #cell(stages)="{ item }">
<div class="stage-cell">
<!-- This empty div should be removed, see https://gitlab.com/gitlab-org/gitlab/-/issues/323488 -->
@@ -229,10 +189,6 @@ export default {
</div>
</template>
- <template #cell(timeago)="{ item }">
- <pipelines-timeago :pipeline="item" />
- </template>
-
<template #cell(actions)="{ item }">
<pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" />
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index c45e3f24567..cde963e4051 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,6 +1,5 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
@@ -8,7 +7,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: { GlIcon },
- mixins: [timeagoMixin, glFeatureFlagMixin()],
+ mixins: [timeagoMixin],
props: {
pipeline: {
type: Object,
@@ -54,14 +53,11 @@ export default {
showSkipped() {
return !this.duration && !this.finishedTime && this.skipped;
},
- shouldDisplayAsBlock() {
- return this.glFeatures?.rearrangePipelinesTable;
- },
},
};
</script>
<template>
- <div class="{ 'gl-display-block': shouldDisplayAsBlock }">
+ <div class="gl-display-block">
<span v-if="showInProgress" data-testid="pipeline-in-progress">
<gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" />
<gl-icon
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
index 5409e68cdc4..1db2898b72a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
@@ -35,6 +35,13 @@ export default {
Api.branches(this.config.projectId, searchterm)
.then(({ data }) => {
this.branches = data.map((branch) => branch.name);
+ if (!searchterm && this.config.defaultBranchName) {
+ // Shift the default branch to the top of the list
+ this.branches = this.branches.filter(
+ (branch) => branch !== this.config.defaultBranchName,
+ );
+ this.branches.unshift(this.config.defaultBranchName);
+ }
this.loading = false;
})
.catch((err) => {
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index bfb95e5ab0c..801f71cb364 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -69,9 +69,7 @@ export default async function initPipelineDetailsBundle() {
}
try {
- if (gon.features?.jobsTabVue) {
- createPipelineJobsApp(SELECTORS.PIPELINE_JOBS);
- }
+ createPipelineJobsApp(SELECTORS.PIPELINE_JOBS);
} catch {
createFlash({
message: __('An error occurred while loading the Jobs tab.'),
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index c4c2b5f2927..f4d9a44a754 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -36,9 +36,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
ciLintPath,
resetCachePath,
projectId,
+ defaultBranchName,
params,
- codeQualityPagePath,
ciRunnerSettingsPath,
+ anyRunnersAvailable,
} = el.dataset;
return new Vue({
@@ -75,9 +76,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
ciLintPath,
resetCachePath,
projectId,
+ defaultBranchName,
params: JSON.parse(params),
- codeQualityPagePath,
ciRunnerSettingsPath,
+ anyRunnersAvailable: parseBoolean(anyRunnersAvailable),
},
});
},
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index ff9b47cdcd6..25fefff219c 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import createFlash from '~/flash';
+import createFlash, { FLASH_TYPES } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { Rails } from '~/lib/utils/rails_ujs';
@@ -86,7 +86,7 @@ export default class Profile {
createFlash({
message: data.message,
- type: 'notice',
+ type: data.status === 'error' ? FLASH_TYPES.ALERT : FLASH_TYPES.NOTICE,
});
})
.then(() => {
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index 94d32609e5d..28b77f6defd 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -11,7 +11,13 @@ const apolloProvider = new VueApollo({
});
const mountPipelineChartsApp = (el) => {
- const { projectPath, failedPipelinesLink, coverageChartPath, defaultBranch } = el.dataset;
+ const {
+ projectPath,
+ failedPipelinesLink,
+ coverageChartPath,
+ defaultBranch,
+ testRunsEmptyStateImagePath,
+ } = el.dataset;
const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts);
const shouldRenderQualitySummary = parseBoolean(el.dataset.shouldRenderQualitySummary);
@@ -30,6 +36,7 @@ const mountPipelineChartsApp = (el) => {
shouldRenderQualitySummary,
coverageChartPath,
defaultBranch,
+ testRunsEmptyStateImagePath,
},
render: (createElement) => createElement(ProjectPipelinesCharts, {}),
});
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 62e2cec874a..f1b7e3df7d6 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -120,15 +120,6 @@ 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 = () => {
@@ -153,8 +144,8 @@ const bindEvents = () => {
bindHowToImport();
- $('.btn_import_gitlab_project').on('click', () => {
- const importHref = $('a.btn_import_gitlab_project').attr('href');
+ $('.btn_import_gitlab_project').on('click contextmenu', () => {
+ const importHref = $('a.btn_import_gitlab_project').attr('data-href');
$('.btn_import_gitlab_project').attr(
'href',
`${importHref}?namespace_id=${$(
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index d4b52860261..16eb5c3de32 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -5,6 +5,7 @@ import AccessorUtilities from '~/lib/utils/accessor';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
+import { initToggle } from '~/toggles';
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
export default class ProtectedBranchCreate {
@@ -15,25 +16,18 @@ export default class ProtectedBranchCreate {
this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
this.currentProjectUserDefaults = {};
this.buildDropdowns();
- this.$forcePushToggle = this.$form.find('.js-force-push-toggle');
- this.$codeOwnerToggle = this.$form.find('.js-code-owner-toggle');
- this.bindEvents();
- }
- bindEvents() {
- this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this));
+ this.forcePushToggle = initToggle(document.querySelector('.js-force-push-toggle'));
+
if (this.hasLicense) {
- this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this));
+ this.codeOwnerToggle = initToggle(document.querySelector('.js-code-owner-toggle'));
}
- this.$form.on('submit', this.onFormSubmit.bind(this));
- }
- onForcePushToggleClick() {
- this.$forcePushToggle.toggleClass('is-checked');
+ this.bindEvents();
}
- onCodeOwnerToggleClick() {
- this.$codeOwnerToggle.toggleClass('is-checked');
+ bindEvents() {
+ this.$form.on('submit', this.onFormSubmit.bind(this));
}
buildDropdowns() {
@@ -92,8 +86,8 @@ export default class ProtectedBranchCreate {
authenticity_token: this.$form.find('input[name="authenticity_token"]').val(),
protected_branch: {
name: this.$form.find('input[name="protected_branch[name]"]').val(),
- allow_force_push: this.$forcePushToggle.hasClass('is-checked'),
- code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'),
+ allow_force_push: this.forcePushToggle.value,
+ code_owner_approval_required: this.codeOwnerToggle?.value ?? false,
},
};
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 86273cfdda6..15e706e38c6 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -3,6 +3,7 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
+import { initToggle } from '~/toggles';
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
export default class ProtectedBranchEdit {
@@ -14,8 +15,6 @@ export default class ProtectedBranchEdit {
this.$wrap = options.$wrap;
this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
- this.$forcePushToggle = this.$wrap.find('.js-force-push-toggle');
- this.$codeOwnerToggle = this.$wrap.find('.js-code-owner-toggle');
this.$wraps[ACCESS_LEVELS.MERGE] = this.$allowedToMergeDropdown.closest(
`.${ACCESS_LEVELS.MERGE}-container`,
@@ -25,36 +24,47 @@ export default class ProtectedBranchEdit {
);
this.buildDropdowns();
- this.bindEvents();
+ this.initToggles();
}
- bindEvents() {
- this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this));
- if (this.hasLicense) {
- this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this));
+ initToggles() {
+ const wrap = this.$wrap.get(0);
+
+ const forcePushToggle = initToggle(wrap.querySelector('.js-force-push-toggle'));
+ if (forcePushToggle) {
+ forcePushToggle.$on('change', (value) => {
+ forcePushToggle.isLoading = true;
+ forcePushToggle.disabled = true;
+ this.updateProtectedBranch(
+ {
+ allow_force_push: value,
+ },
+ () => {
+ forcePushToggle.isLoading = false;
+ forcePushToggle.disabled = false;
+ },
+ );
+ });
}
- }
-
- onForcePushToggleClick() {
- this.$forcePushToggle.toggleClass('is-checked');
- this.$forcePushToggle.prop('disabled', true);
-
- const formData = {
- allow_force_push: this.$forcePushToggle.hasClass('is-checked'),
- };
-
- this.updateProtectedBranch(formData, () => this.$forcePushToggle.prop('disabled', false));
- }
- onCodeOwnerToggleClick() {
- this.$codeOwnerToggle.toggleClass('is-checked');
- this.$codeOwnerToggle.prop('disabled', true);
-
- const formData = {
- code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'),
- };
-
- this.updateProtectedBranch(formData, () => this.$codeOwnerToggle.prop('disabled', false));
+ if (this.hasLicense) {
+ const codeOwnerToggle = initToggle(wrap.querySelector('.js-code-owner-toggle'));
+ if (codeOwnerToggle) {
+ codeOwnerToggle.$on('change', (value) => {
+ codeOwnerToggle.isLoading = true;
+ codeOwnerToggle.disabled = true;
+ this.updateProtectedBranch(
+ {
+ code_owner_approval_required: value,
+ },
+ () => {
+ codeOwnerToggle.isLoading = false;
+ codeOwnerToggle.disabled = false;
+ },
+ );
+ });
+ }
+ }
}
updateProtectedBranch(formData, callback) {
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index ce781c64006..d02526160fd 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -58,6 +58,11 @@ export default {
required: false,
default: () => ({}),
},
+ useSymbolicRefNames: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
/** The validation state of this component. */
state: {
@@ -121,8 +126,15 @@ export default {
query: this.lastQuery,
};
},
+ selectedRefForDisplay() {
+ if (this.useSymbolicRefNames && this.selectedRef) {
+ return this.selectedRef.replace(/^refs\/(tags|heads)\//, '');
+ }
+
+ return this.selectedRef;
+ },
buttonText() {
- return this.selectedRef || this.i18n.noRefSelected;
+ return this.selectedRefForDisplay || this.i18n.noRefSelected;
},
},
watch: {
@@ -164,9 +176,20 @@ export default {
},
{ immediate: true },
);
+
+ this.$watch(
+ 'useSymbolicRefNames',
+ () => this.setUseSymbolicRefNames(this.useSymbolicRefNames),
+ { immediate: true },
+ );
},
methods: {
- ...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef']),
+ ...mapActions([
+ 'setEnabledRefTypes',
+ 'setUseSymbolicRefNames',
+ 'setProjectId',
+ 'setSelectedRef',
+ ]),
...mapActions({ storeSearch: 'search' }),
focusSearchBox() {
this.$refs.searchBox.$el.querySelector('input').focus();
diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js
index 3832cc0c21d..a6019f21e73 100644
--- a/app/assets/javascripts/ref/stores/actions.js
+++ b/app/assets/javascripts/ref/stores/actions.js
@@ -5,6 +5,9 @@ import * as types from './mutation_types';
export const setEnabledRefTypes = ({ commit }, refTypes) =>
commit(types.SET_ENABLED_REF_TYPES, refTypes);
+export const setUseSymbolicRefNames = ({ commit }, useSymbolicRefNames) =>
+ commit(types.SET_USE_SYMBOLIC_REF_NAMES, useSymbolicRefNames);
+
export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
export const setSelectedRef = ({ commit }, selectedRef) =>
diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js
index c26f4fa00c7..4c602908cae 100644
--- a/app/assets/javascripts/ref/stores/mutation_types.js
+++ b/app/assets/javascripts/ref/stores/mutation_types.js
@@ -1,4 +1,5 @@
export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES';
+export const SET_USE_SYMBOLIC_REF_NAMES = 'SET_USE_SYMBOLIC_REF_NAMES';
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_SELECTED_REF = 'SET_SELECTED_REF';
diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js
index f91cbae8462..e078d3333d4 100644
--- a/app/assets/javascripts/ref/stores/mutations.js
+++ b/app/assets/javascripts/ref/stores/mutations.js
@@ -7,6 +7,9 @@ export default {
[types.SET_ENABLED_REF_TYPES](state, refTypes) {
state.enabledRefTypes = refTypes;
},
+ [types.SET_USE_SYMBOLIC_REF_NAMES](state, useSymbolicRefNames) {
+ state.useSymbolicRefNames = useSymbolicRefNames;
+ },
[types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId;
},
@@ -28,6 +31,7 @@ export default {
state.matches.branches = {
list: convertObjectPropsToCamelCase(response.data).map((b) => ({
name: b.name,
+ value: state.useSymbolicRefNames ? `refs/heads/${b.name}` : undefined,
default: b.default,
})),
totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10),
@@ -46,6 +50,7 @@ export default {
state.matches.tags = {
list: convertObjectPropsToCamelCase(response.data).map((b) => ({
name: b.name,
+ value: state.useSymbolicRefNames ? `refs/tags/${b.name}` : undefined,
})),
totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10),
error: null,
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index bc97fab9ad2..eeb4c254a1b 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -85,6 +85,16 @@ export default {
required: false,
default: true,
},
+ autoCompleteEpics: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autoCompleteIssues: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
hasRelatedIssues() {
@@ -198,6 +208,8 @@ export default {
:input-value="inputValue"
:pending-references="pendingReferences"
:auto-complete-sources="autoCompleteSources"
+ :auto-complete-epics="autoCompleteEpics"
+ :auto-complete-issues="autoCompleteIssues"
:path-id-separator="pathIdSeparator"
@pendingIssuableRemoveRequest="$emit('pendingIssuableRemoveRequest', $event)"
@addIssuableFormInput="$emit('addIssuableFormInput', $event)"
@@ -210,6 +222,7 @@ export default {
<related-issues-list
v-for="category in categorisedIssues"
:key="category.linkType"
+ :list-link-type="category.linkType"
:heading="$options.linkedIssueTypesTextMap[category.linkType]"
:can-admin="canAdmin"
:can-reorder="canReorder"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index 8b39851405e..174049b15fe 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -21,6 +21,11 @@ export default {
required: false,
default: false,
},
+ listLinkType: {
+ type: String,
+ required: false,
+ default: '',
+ },
heading: {
type: String,
required: false,
@@ -91,7 +96,7 @@ export default {
</script>
<template>
- <div>
+ <div :data-link-type="listLinkType">
<h4 v-if="heading" class="gl-font-base mt-0">{{ heading }}</h4>
<div
class="related-issues-token-body bordered-box bg-white"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue
index 7e2fda8495c..40d58c04753 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -71,6 +71,16 @@ export default {
required: false,
default: true,
},
+ autoCompleteEpics: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autoCompleteIssues: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
pathIdSeparator: {
type: String,
required: false,
@@ -241,6 +251,8 @@ export default {
:is-form-visible="isFormVisible"
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources"
+ :auto-complete-epics="autoCompleteEpics"
+ :auto-complete-issues="autoCompleteIssues"
:issuable-type="issuableType"
:path-id-separator="pathIdSeparator"
:show-categorized-issues="showCategorizedIssues"
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index 35858be90b2..b61f1cf2470 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -21,6 +21,7 @@ export default function initRelatedIssues() {
showCategorizedIssues: parseBoolean(
relatedIssuesRootElement.dataset.showCategorizedIssues,
),
+ autoCompleteEpics: false,
},
}),
});
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index c2c91f406a1..e53bfea7389 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -68,7 +68,7 @@ export default {
:href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary"
- variant="success"
+ variant="confirm"
data-testid="new-release-button"
>
{{ __('New release') }}
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index b9601428850..b81da399a7b 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -56,6 +56,9 @@ export default {
hasDuplicateUrl(link) {
return Boolean(this.getLinkErrors(link).isDuplicate);
},
+ hasDuplicateName(link) {
+ return Boolean(this.getLinkErrors(link).isTitleDuplicate);
+ },
hasBadFormat(link) {
return Boolean(this.getLinkErrors(link).isBadFormat);
},
@@ -72,7 +75,7 @@ export default {
return !this.hasDuplicateUrl(link) && !this.hasBadFormat(link) && !this.hasEmptyUrl(link);
},
isNameValid(link) {
- return !this.hasEmptyName(link);
+ return !this.hasEmptyName(link) && !this.hasDuplicateName(link);
},
/**
@@ -121,7 +124,7 @@ export default {
<p>
{{
__(
- 'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Duplicate URLs are not allowed.',
+ 'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Each URL and link title must be unique.',
)
}}
</p>
@@ -165,7 +168,7 @@ export default {
</gl-sprintf>
</span>
<span v-else-if="hasDuplicateUrl(link)" class="invalid-feedback d-inline">
- {{ __('This URL is already used for another link; duplicate URLs are not allowed') }}
+ {{ __('This URL already exists.') }}
</span>
</template>
</gl-form-group>
@@ -191,6 +194,9 @@ export default {
<span v-if="hasEmptyName(link)" class="invalid-feedback d-inline">
{{ __('Link title is required') }}
</span>
+ <span v-else-if="hasDuplicateName(link)" class="invalid-feedback d-inline">
+ {{ __('This title already exists.') }}
+ </span>
</template>
</gl-form-group>
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 576f099248e..b3ba4f9263a 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -162,7 +162,7 @@ const createReleaseLink = async ({ state, link }) => {
input: {
projectPath: state.projectPath,
tagName: state.tagName,
- name: link.name,
+ name: link.name.trim(),
url: link.url,
linkType: link.linkType.toUpperCase(),
directAssetPath: link.directAssetPath,
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index d83ec05872a..d4f49e53619 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -1,5 +1,6 @@
import { isEmpty } from 'lodash';
import { hasContent } from '~/lib/utils/text_utility';
+import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility';
/**
* @returns {Boolean} `true` if the app is editing an existing release.
@@ -95,6 +96,17 @@ export const validationErrors = (state) => {
}
});
+ // check for duplicated Link Titles
+ const linkTitles = state.release.assets.links.map((link) => link.name.trim());
+ const duplicatedTitles = getDuplicateItemsFromArray(linkTitles);
+
+ // add a validation error for each link that shares Link Title
+ state.release.assets.links.forEach((link) => {
+ if (hasContent(link.name) && duplicatedTitles.includes(link.name.trim())) {
+ errors.assets.links[link.id].isTitleDuplicate = true;
+ }
+ });
+
return errors;
};
@@ -131,7 +143,7 @@ export const releaseCreateMutatationVariables = (state, getters) => {
ref: state.createFrom,
assets: {
links: getters.releaseLinksToCreate.map(({ name, url, linkType }) => ({
- name,
+ name: name.trim(),
url,
linkType: linkType.toUpperCase(),
})),
diff --git a/app/assets/javascripts/reports/codequality_report/constants.js b/app/assets/javascripts/reports/codequality_report/constants.js
index 502977e714c..0c472b24471 100644
--- a/app/assets/javascripts/reports/codequality_report/constants.js
+++ b/app/assets/javascripts/reports/codequality_report/constants.js
@@ -15,3 +15,17 @@ export const SEVERITY_ICONS = {
blocker: 'severity-critical',
unknown: 'severity-unknown',
};
+
+// This is the icons mapping for the code Quality Merge-Request Widget Extension
+// once the refactor_mr_widgets_extensions flag is activated the above SEVERITY_ICONS
+// need be removed and this variable needs to be rename to SEVERITY_ICONS
+// Rollout Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/341759
+
+export const SEVERITY_ICONS_EXTENSION = {
+ info: 'severityInfo',
+ minor: 'severityLow',
+ major: 'severityMedium',
+ critical: 'severityHigh',
+ blocker: 'severityCritical',
+ unknown: 'severityUnknown',
+};
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index 53273aeff33..bad6fa1e7b9 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -18,6 +18,7 @@ export const ICON_WARNING = 'warning';
export const ICON_SUCCESS = 'success';
export const ICON_NOTFOUND = 'notfound';
export const ICON_PENDING = 'pending';
+export const ICON_FAILED = 'failed';
export const status = {
LOADING,
diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue
index 857795c71b0..d79ccde61a8 100644
--- a/app/assets/javascripts/repository/components/blob_button_group.vue
+++ b/app/assets/javascripts/repository/components/blob_button_group.vue
@@ -7,6 +7,8 @@ import getRefMixin from '../mixins/get_ref';
import DeleteBlobModal from './delete_blob_modal.vue';
import UploadBlobModal from './upload_blob_modal.vue';
+const REPLACE_BLOB_MODAL_ID = 'modal-replace-blob';
+
export default {
i18n: {
replace: __('Replace'),
@@ -76,9 +78,6 @@ export default {
},
},
computed: {
- replaceModalId() {
- return uniqueId('replace-modal');
- },
replaceModalTitle() {
return sprintf(__('Replace %{name}'), { name: this.name });
},
@@ -95,13 +94,14 @@ export default {
methods: {
showModal(modalId) {
if (this.showForkSuggestion) {
- this.$emit('fork');
+ this.$emit('fork', 'view');
return;
}
this.$refs[modalId].show();
},
},
+ replaceBlobModalId: REPLACE_BLOB_MODAL_ID,
};
</script>
@@ -118,7 +118,7 @@ export default {
data-testid="lock"
:data-qa-selector="lockBtnQASelector"
/>
- <gl-button data-testid="replace" @click="showModal(replaceModalId)">
+ <gl-button data-testid="replace" @click="showModal($options.replaceBlobModalId)">
{{ $options.i18n.replace }}
</gl-button>
<gl-button data-testid="delete" @click="showModal(deleteModalId)">
@@ -126,8 +126,8 @@ export default {
</gl-button>
</gl-button-group>
<upload-blob-modal
- :ref="replaceModalId"
- :modal-id="replaceModalId"
+ :ref="$options.replaceBlobModalId"
+ :modal-id="$options.replaceBlobModalId"
:modal-title="replaceModalTitle"
:commit-message="replaceModalTitle"
:target-branch="targetBranch || ref"
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 52963b49f68..85652301f4d 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -10,11 +10,14 @@ import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
+import CodeIntelligence from '~/code_navigation/components/app.vue';
import getRefMixin from '../mixins/get_ref';
import blobInfoQuery from '../queries/blob_info.query.graphql';
+import userInfoQuery from '../queries/user_info.query.graphql';
+import applicationInfoQuery from '../queries/application_info.query.graphql';
import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE } from '../constants';
import BlobButtonGroup from './blob_button_group.vue';
-import BlobEdit from './blob_edit.vue';
import ForkSuggestion from './fork_suggestion.vue';
import { loadViewer } from './blob_viewers';
@@ -24,12 +27,13 @@ export default {
},
components: {
BlobHeader,
- BlobEdit,
BlobButtonGroup,
BlobContent,
GlLoadingIcon,
GlButton,
ForkSuggestion,
+ WebIdeLink,
+ CodeIntelligence,
},
mixins: [getRefMixin, glFeatureFlagMixin()],
inject: {
@@ -38,6 +42,18 @@ export default {
},
},
apollo: {
+ gitpodEnabled: {
+ query: applicationInfoQuery,
+ error() {
+ this.displayError();
+ },
+ },
+ currentUser: {
+ query: userInfoQuery,
+ error() {
+ this.displayError();
+ },
+ },
project: {
query: blobInfoQuery,
variables() {
@@ -78,8 +94,11 @@ export default {
legacySimpleViewer: null,
isBinary: false,
isLoadingLegacyViewer: false,
+ isRenderingLegacyTextViewer: false,
activeViewerType: SIMPLE_BLOB_VIEWER,
- project: DEFAULT_BLOB_INFO,
+ project: DEFAULT_BLOB_INFO.project,
+ gitpodEnabled: DEFAULT_BLOB_INFO.gitpodEnabled,
+ currentUser: DEFAULT_BLOB_INFO.currentUser,
};
},
computed: {
@@ -142,9 +161,13 @@ export default {
return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject;
},
forkPath() {
- return this.forkTarget === 'ide'
- ? this.blobInfo.ideForkAndEditPath
- : this.blobInfo.forkAndEditPath;
+ const forkPaths = {
+ ide: this.blobInfo.ideForkAndEditPath,
+ simple: this.blobInfo.forkAndEditPath,
+ view: this.blobInfo.forkAndViewPath,
+ };
+
+ return forkPaths[this.forkTarget];
},
isUsingLfs() {
return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE;
@@ -163,7 +186,13 @@ export default {
.get(`${this.blobInfo.webPath}?format=json&viewer=${type}`)
.then(({ data: { html, binary } }) => {
if (type === SIMPLE_BLOB_VIEWER) {
+ this.isRenderingLegacyTextViewer = true;
+
this.legacySimpleViewer = html;
+
+ window.requestIdleCallback(() => {
+ this.isRenderingLegacyTextViewer = false;
+ });
} else {
this.legacyRichViewer = html;
}
@@ -213,26 +242,25 @@ export default {
@viewer-changed="switchViewer"
>
<template #actions>
- <blob-edit
+ <web-ide-link
v-if="!blobInfo.archived"
:show-edit-button="!isBinaryFileType"
- :edit-path="blobInfo.editBlobPath"
- :web-ide-path="blobInfo.ideEditPath"
+ class="gl-mr-3"
+ :edit-url="blobInfo.editBlobPath"
+ :web-ide-url="blobInfo.ideEditPath"
:needs-to-fork="showForkSuggestion"
+ :show-pipeline-editor-button="Boolean(blobInfo.pipelineEditorPath)"
+ :pipeline-editor-url="blobInfo.pipelineEditorPath"
+ :gitpod-url="blobInfo.gitpodBlobUrl"
+ :show-gitpod-button="gitpodEnabled"
+ :gitpod-enabled="currentUser && currentUser.gitpodEnabled"
+ :user-preferences-gitpod-path="currentUser && currentUser.preferencesGitpodPath"
+ :user-profile-enable-gitpod-path="currentUser && currentUser.profileEnableGitpodPath"
+ is-blob
+ disable-fork-modal
@edit="editBlob"
/>
- <gl-button
- v-if="blobInfo.pipelineEditorPath"
- class="gl-mr-3"
- category="secondary"
- variant="confirm"
- data-testid="pipeline-editor"
- :href="blobInfo.pipelineEditorPath"
- >
- {{ $options.i18n.pipelineEditor }}
- </gl-button>
-
<blob-button-group
v-if="isLoggedIn && !blobInfo.archived"
:path="path"
@@ -246,7 +274,7 @@ export default {
:is-locked="Boolean(pathLockedByUser)"
:can-lock="canLock"
:show-fork-suggestion="showForkSuggestion"
- @fork="setForkTarget('ide')"
+ @fork="setForkTarget('view')"
/>
</template>
</blob-header>
@@ -265,8 +293,15 @@ export default {
:active-viewer="viewer"
:hide-line-numbers="true"
:loading="isLoadingLegacyViewer"
+ :data-loading="isRenderingLegacyTextViewer"
/>
<component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" />
+ <code-intelligence
+ v-if="blobViewer || legacyViewerLoaded"
+ :code-navigation-path="blobInfo.codeNavigationPath"
+ :blob-path="blobInfo.path"
+ :path-prefix="blobInfo.projectBlobPathRoot"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_edit.vue b/app/assets/javascripts/repository/components/blob_edit.vue
deleted file mode 100644
index 69e2bd563c9..00000000000
--- a/app/assets/javascripts/repository/components/blob_edit.vue
+++ /dev/null
@@ -1,78 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
-export default {
- i18n: {
- edit: __('Edit'),
- webIde: __('Web IDE'),
- },
- components: {
- GlButton,
- WebIdeLink,
- },
- mixins: [glFeatureFlagsMixin()],
- props: {
- showEditButton: {
- type: Boolean,
- required: true,
- },
- editPath: {
- type: String,
- required: true,
- },
- webIdePath: {
- type: String,
- required: true,
- },
- needsToFork: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- methods: {
- onEdit(target) {
- this.$emit('edit', target);
- },
- },
-};
-</script>
-
-<template>
- <web-ide-link
- v-if="glFeatures.consolidatedEditButton"
- :show-edit-button="showEditButton"
- class="gl-mr-3"
- :edit-url="editPath"
- :web-ide-url="webIdePath"
- :needs-to-fork="needsToFork"
- :is-blob="true"
- disable-fork-modal
- @edit="onEdit"
- />
- <div v-else>
- <gl-button
- v-if="showEditButton"
- class="gl-mr-2"
- category="primary"
- variant="confirm"
- data-testid="edit"
- @click="onEdit('simple')"
- >
- {{ $options.i18n.edit }}
- </gl-button>
-
- <gl-button
- class="gl-mr-3"
- category="primary"
- variant="confirm"
- data-testid="web-ide"
- @click="onEdit('ide')"
- >
- {{ $options.i18n.webIde }}
- </gl-button>
- </div>
-</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue
new file mode 100644
index 00000000000..048730c02c1
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue
@@ -0,0 +1,20 @@
+<script>
+export default {
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ src: this.blob.rawPath,
+ };
+ },
+};
+</script>
+<template>
+ <div class="gl-text-center gl-p-7">
+ <audio :src="src" controls data-testid="audio"></audio>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue
new file mode 100644
index 00000000000..86a0bb9fad0
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue
@@ -0,0 +1,26 @@
+<script>
+import CsvViewer from '~/blob/csv/csv_viewer.vue';
+
+export default {
+ components: {
+ CsvViewer,
+ },
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ url: this.blob.rawPath,
+ };
+ },
+};
+</script>
+
+<template>
+ <div>
+ <csv-viewer :csv="url" remote-file data-testid="csv" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
index f7b318c64d9..be5e9685ccd 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
@@ -17,7 +17,7 @@ export default {
data() {
return {
fileName: this.blob.name,
- filePath: this.blob.rawPath,
+ filePath: this.blob.externalStorageUrl || this.blob.rawPath,
fileSize: this.blob.rawSize || 0,
};
},
diff --git a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
index 5027f7877aa..014f1abc121 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
@@ -16,6 +16,6 @@ export default {
</script>
<template>
<div class="gl-text-center gl-p-7 gl-bg-gray-50">
- <img :src="url" :alt="alt" data-testid="image" />
+ <img :src="url" :alt="alt" data-testid="image" class="gl-max-w-full" />
</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 e942f59e7d8..cbe18ea396e 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -1,4 +1,5 @@
const viewers = {
+ csv: () => import('./csv_viewer.vue'),
download: () => import('./download_viewer.vue'),
image: () => import('./image_viewer.vue'),
video: () => import('./video_viewer.vue'),
@@ -6,6 +7,7 @@ const viewers = {
text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'),
pdf: () => import('./pdf_viewer.vue'),
lfs: () => import('./lfs_viewer.vue'),
+ audio: () => import('./audio_viewer.vue'),
};
export const loadViewer = (type, isUsingLfs) => {
diff --git a/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue
index 6dc7e10662e..9d39764e9a4 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue
@@ -21,7 +21,7 @@ export default {
data() {
return {
fileName: this.blob.name,
- filePath: this.blob.rawPath,
+ filePath: this.blob.externalStorageUrl || this.blob.rawPath,
};
},
};
diff --git a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
index c3df5984426..37c8f636757 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
@@ -18,7 +18,7 @@ export default {
},
data() {
return {
- url: this.blob.rawPath,
+ url: this.blob.externalStorageUrl || this.blob.rawPath,
fileSize: this.blob.rawSize,
totalPages: 0,
};
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index d3717f10ec7..08faf19d12a 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -148,11 +148,16 @@ export default {
.reduce(
(acc, name, i) => {
const path = joinPaths(i > 0 ? acc[i].path : '', escapeFileUrl(name));
+ const isLastPath = i === this.currentPath.split('/').length - 1;
+ const to =
+ this.isBlobPath && isLastPath
+ ? `/-/blob/${joinPaths(this.escapedRef, path)}`
+ : `/-/tree/${joinPaths(this.escapedRef, path)}`;
return acc.concat({
name,
path,
- to: `/-/tree/${joinPaths(this.escapedRef, path)}`,
+ to,
});
},
[
@@ -274,9 +279,11 @@ export default {
return items;
},
+ isBlobPath() {
+ return this.$route.name === 'blobPath' || this.$route.name === 'blobPathDecoded';
+ },
renderAddToTreeDropdown() {
- const isBlobPath = this.$route.name === 'blobPath' || this.$route.name === 'blobPathDecoded';
- return !isBlobPath && (this.canCollaborate || this.canCreateMrFromFork);
+ return !this.isBlobPath && (this.canCollaborate || this.canCreateMrFromFork);
},
},
methods: {
diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue
index f3c9aea36f1..baf8449b188 100644
--- a/app/assets/javascripts/repository/components/delete_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue
@@ -87,7 +87,7 @@ export default {
fields: {
// fields key must match case of form name for validation directive to work
commit_message: initFormField({ value: this.commitMessage }),
- branch_name: initFormField({ value: this.targetBranch }),
+ branch_name: initFormField({ value: this.targetBranch, skipValidation: !this.canPushCode }),
},
};
return {
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index e206d9bfbd2..bb9d3180be8 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -27,6 +27,12 @@ export const PDF_MAX_PAGE_LIMIT = 50;
export const ROW_APPEAR_DELAY = 150;
export const DEFAULT_BLOB_INFO = {
+ gitpodEnabled: false,
+ currentUser: {
+ gitpodEnabled: false,
+ preferencesGitpodPath: null,
+ profileEnableGitpodPath: null,
+ },
userPermissions: {
pushCode: false,
downloadCode: false,
@@ -49,9 +55,13 @@ export const DEFAULT_BLOB_INFO = {
tooLarge: false,
path: '',
editBlobPath: '',
+ gitpodBlobUrl: '',
ideEditPath: '',
forkAndEditPath: '',
ideForkAndEditPath: '',
+ codeNavigationPath: '',
+ projectBlobPathRoot: '',
+ forkAndViewPath: '',
storedExternally: false,
externalStorage: '',
environmentFormattedExternalUrl: '',
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 120c32caefd..b38a1cfdc7b 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,10 +1,12 @@
import { GlButton } from '@gitlab/ui';
import Vue from 'vue';
+import Vuex from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import PerformancePlugin from '~/performance/vue_performance_plugin';
+import createStore from '~/code_navigation/store';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
@@ -19,6 +21,7 @@ import createRouter from './router';
import { updateFormAction } from './utils/dom';
import { setTitle } from './utils/title';
+Vue.use(Vuex);
Vue.use(PerformancePlugin, {
components: ['SimpleViewer', 'BlobContent'],
});
@@ -200,6 +203,7 @@ export default function setupVueRepositoryList() {
// eslint-disable-next-line no-new
new Vue({
el,
+ store: createStore(),
router,
apolloProvider,
render(h) {
diff --git a/app/assets/javascripts/repository/queries/application_info.query.graphql b/app/assets/javascripts/repository/queries/application_info.query.graphql
new file mode 100644
index 00000000000..fd69de39f75
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/application_info.query.graphql
@@ -0,0 +1,3 @@
+query getApplicationInfo {
+ gitpodEnabled
+}
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index 78323fdc5f4..8baee80e5d6 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -28,9 +28,13 @@ query getBlobInfo(
language
path
editBlobPath
+ gitpodBlobUrl
ideEditPath
forkAndEditPath
ideForkAndEditPath
+ codeNavigationPath
+ projectBlobPathRoot
+ forkAndViewPath
environmentFormattedExternalUrl
environmentExternalUrlForRouteMap
canModifyBlob
diff --git a/app/assets/javascripts/repository/queries/user_info.query.graphql b/app/assets/javascripts/repository/queries/user_info.query.graphql
new file mode 100644
index 00000000000..114947a423d
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/user_info.query.graphql
@@ -0,0 +1,8 @@
+query getUserInfo {
+ currentUser {
+ id
+ gitpodEnabled
+ preferencesGitpodPath
+ profileEnableGitpodPath
+ }
+}
diff --git a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue b/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue
index 4d2ca9b0c58..c2db3b9facd 100644
--- a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue
+++ b/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue
@@ -5,7 +5,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '../components/runner_header.vue';
import RunnerUpdateForm from '../components/runner_update_form.vue';
import { I18N_FETCH_ERROR } from '../constants';
-import getRunnerQuery from '../graphql/get_runner.query.graphql';
+import runnerQuery from '../graphql/details/runner.query.graphql';
import { captureException } from '../sentry_utils';
export default {
@@ -27,7 +27,7 @@ export default {
},
apollo: {
runner: {
- query: getRunnerQuery,
+ query: runnerQuery,
variables() {
return {
id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
index 2795ddbbbcb..86ad912f017 100644
--- a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
@@ -8,7 +8,7 @@ import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
import RunnerDetails from '../components/runner_details.vue';
import { I18N_FETCH_ERROR } from '../constants';
-import getRunnerQuery from '../graphql/get_runner.query.graphql';
+import runnerQuery from '../graphql/details/runner.query.graphql';
import { captureException } from '../sentry_utils';
export default {
@@ -35,7 +35,7 @@ export default {
},
apollo: {
runner: {
- query: getRunnerQuery,
+ query: runnerQuery,
variables() {
return {
id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
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 a968d4029f8..8aba91eedf7 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -12,6 +12,7 @@ import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
+import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
@@ -25,8 +26,8 @@ import {
STATUS_STALE,
I18N_FETCH_ERROR,
} from '../constants';
-import getRunnersQuery from '../graphql/get_runners.query.graphql';
-import getRunnersCountQuery from '../graphql/get_runners_count.query.graphql';
+import runnersAdminQuery from '../graphql/list/admin_runners.query.graphql';
+import runnersAdminCountQuery from '../graphql/list/admin_runners_count.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
@@ -35,7 +36,7 @@ import {
import { captureException } from '../sentry_utils';
const runnersCountSmartQuery = {
- query: getRunnersCountQuery,
+ query: runnersAdminCountQuery,
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
update(data) {
return data?.runners?.count;
@@ -57,6 +58,7 @@ export default {
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
+ RunnerActionsCell,
},
props: {
registrationToken: {
@@ -75,7 +77,7 @@ export default {
},
apollo: {
runners: {
- query: getRunnersQuery,
+ query: runnersAdminQuery,
// Runners can be updated by users directly in this list.
// A "cache and network" policy prevents outdated filtered
// results.
@@ -187,6 +189,7 @@ export default {
deep: true,
handler() {
// TODO Implement back button response using onpopstate
+ // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333804
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
@@ -221,6 +224,10 @@ export default {
}
return '';
},
+ onDeleted({ message }) {
+ this.$root.$toast?.show(message);
+ this.$apollo.queries.runners.refetch();
+ },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
@@ -278,6 +285,13 @@ export default {
<runner-name :runner="runner" />
</gl-link>
</template>
+ <template #runner-actions-cell="{ runner }">
+ <runner-actions-cell
+ :runner="runner"
+ :edit-url="runner.editAdminUrl"
+ @deleted="onDeleted"
+ />
+ </template>
</runner-list>
<runner-pagination
v-model="search.pagination"
diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
index ae9c774f2a2..c69321de001 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -1,60 +1,30 @@
<script>
-import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
-import { s__, sprintf } from '~/locale';
-import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
-import { captureException } from '~/runner/sentry_utils';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { GlButtonGroup } from '@gitlab/ui';
import RunnerEditButton from '../runner_edit_button.vue';
import RunnerPauseButton from '../runner_pause_button.vue';
-import RunnerDeleteModal from '../runner_delete_modal.vue';
-
-const I18N_DELETE = s__('Runners|Delete runner');
-const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
+import RunnerDeleteButton from '../runner_delete_button.vue';
export default {
name: 'RunnerActionsCell',
components: {
- GlButton,
GlButtonGroup,
RunnerEditButton,
RunnerPauseButton,
- RunnerDeleteModal,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- GlModal: GlModalDirective,
+ RunnerDeleteButton,
},
props: {
runner: {
type: Object,
required: true,
},
+ editUrl: {
+ type: String,
+ default: null,
+ required: false,
+ },
},
- data() {
- return {
- updating: false,
- deleting: false,
- };
- },
+ emits: ['deleted'],
computed: {
- deleteTitle() {
- if (this.deleting) {
- // Prevent a "sticky" tooltip: If this button is disabled,
- // mouseout listeners don't run leaving the tooltip stuck
- return '';
- }
- return I18N_DELETE;
- },
- runnerId() {
- return getIdFromGraphQLId(this.runner.id);
- },
- runnerName() {
- return `#${this.runnerId} (${this.runner.shortSha})`;
- },
- runnerDeleteModalId() {
- return `delete-runner-modal-${this.runnerId}`;
- },
canUpdate() {
return this.runner.userPermissions?.updateRunner;
},
@@ -63,79 +33,17 @@ export default {
},
},
methods: {
- async onDelete() {
- // Deleting stays "true" until this row is removed,
- // should only change back if the operation fails.
- this.deleting = true;
- try {
- const {
- data: {
- runnerDelete: { errors },
- },
- } = await this.$apollo.mutate({
- mutation: runnerDeleteMutation,
- variables: {
- input: {
- id: this.runner.id,
- },
- },
- awaitRefetchQueries: true,
- refetchQueries: ['getRunners', 'getGroupRunners'],
- });
- if (errors && errors.length) {
- throw new Error(errors.join(' '));
- } else {
- // Use $root to have the toast message stay after this element is removed
- this.$root.$toast?.show(sprintf(I18N_DELETED_TOAST, { name: this.runnerName }));
- }
- } catch (e) {
- this.deleting = false;
- this.onError(e);
- }
- },
-
- onError(error) {
- const { message } = error;
- createAlert({ message });
-
- this.reportToSentry(error);
- },
- reportToSentry(error) {
- captureException({ error, component: this.$options.name });
+ onDeleted(value) {
+ this.$emit('deleted', value);
},
},
- I18N_DELETE,
};
</script>
<template>
<gl-button-group>
- <!--
- This button appears for administrators: those with
- access to the adminUrl. More advanced permissions policies
- will allow more granular permissions.
-
- See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
- -->
- <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
+ <runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" />
<runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
- <gl-button
- v-if="canDelete"
- v-gl-tooltip.hover.viewport="deleteTitle"
- v-gl-modal="runnerDeleteModalId"
- :aria-label="deleteTitle"
- icon="close"
- :loading="deleting"
- variant="danger"
- data-testid="delete-runner"
- />
-
- <runner-delete-modal
- v-if="canDelete"
- :ref="runnerDeleteModalId"
- :modal-id="runnerDeleteModalId"
- :runner-name="runnerName"
- @primary="onDelete"
- />
+ <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" />
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
index 54c35e483dc..1234054c660 100644
--- a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
+++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
@@ -4,7 +4,7 @@ import { createAlert } from '~/flash';
import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
-import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
+import runnersRegistrationTokenResetMutation from '~/runner/graphql/list/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
@@ -98,17 +98,14 @@ export default {
},
onError(error) {
const { message } = error;
- createAlert({ message });
- this.reportToSentry(error);
+ createAlert({ message });
+ captureException({ error, component: this.$options.name });
},
onSuccess(token) {
this.$toast?.show(s__('Runners|New registration token generated!'));
this.$emit('tokenReset', token);
},
- reportToSentry(error) {
- captureException({ error, component: this.$options.name });
- },
},
};
</script>
diff --git a/app/assets/javascripts/runner/components/runner_delete_button.vue b/app/assets/javascripts/runner/components/runner_delete_button.vue
new file mode 100644
index 00000000000..854c983f4da
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_delete_button.vue
@@ -0,0 +1,144 @@
+<script>
+import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import runnerDeleteMutation from '~/runner/graphql/shared/runner_delete.mutation.graphql';
+import { createAlert } from '~/flash';
+import { sprintf } from '~/locale';
+import { captureException } from '~/runner/sentry_utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants';
+import RunnerDeleteModal from './runner_delete_modal.vue';
+
+export default {
+ name: 'RunnerDeleteButton',
+ components: {
+ GlButton,
+ RunnerDeleteModal,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ validator: (runner) => {
+ return runner?.id && runner?.shortSha;
+ },
+ },
+ compact: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ emits: ['deleted'],
+ data() {
+ return {
+ deleting: false,
+ };
+ },
+ computed: {
+ runnerId() {
+ return getIdFromGraphQLId(this.runner.id);
+ },
+ runnerName() {
+ return `#${this.runnerId} (${this.runner.shortSha})`;
+ },
+ runnerDeleteModalId() {
+ return `delete-runner-modal-${this.runnerId}`;
+ },
+ icon() {
+ if (this.compact) {
+ return 'close';
+ }
+ return '';
+ },
+ buttonContent() {
+ if (this.compact) {
+ return null;
+ }
+ return I18N_DELETE_RUNNER;
+ },
+ buttonClass() {
+ // Ensure a square button is shown when compact: true.
+ // Without this class we will have distorted/rectangular button.
+ if (this.compact) {
+ return 'btn-icon';
+ }
+ return null;
+ },
+ ariaLabel() {
+ if (this.compact) {
+ return I18N_DELETE_RUNNER;
+ }
+ return null;
+ },
+ tooltip() {
+ // Only show tooltip when compact.
+ // Also prevent a "sticky" tooltip: If this button is
+ // disabled, mouseout listeners don't run leaving the tooltip stuck
+ if (this.compact && !this.deleting) {
+ return I18N_DELETE_RUNNER;
+ }
+ return '';
+ },
+ },
+ methods: {
+ async onDelete() {
+ // Deleting stays "true" until this row is removed,
+ // should only change back if the operation fails.
+ this.deleting = true;
+ try {
+ const {
+ data: {
+ runnerDelete: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnerDeleteMutation,
+ variables: {
+ input: {
+ id: this.runner.id,
+ },
+ },
+ });
+ if (errors && errors.length) {
+ throw new Error(errors.join(' '));
+ } else {
+ this.$emit('deleted', {
+ message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }),
+ });
+ }
+ } catch (e) {
+ this.deleting = false;
+ this.onError(e);
+ }
+ },
+ onError(error) {
+ const { message } = error;
+
+ createAlert({ message });
+ captureException({ error, component: this.$options.name });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover.viewport="tooltip"
+ v-gl-modal="runnerDeleteModalId"
+ :aria-label="ariaLabel"
+ :icon="icon"
+ :class="buttonClass"
+ :loading="deleting"
+ variant="danger"
+ >
+ {{ buttonContent }}
+ <runner-delete-modal
+ :modal-id="runnerDeleteModalId"
+ :runner-name="runnerName"
+ @primary="onDelete"
+ />
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_edit_button.vue b/app/assets/javascripts/runner/components/runner_edit_button.vue
index b115be09e69..33e0acaf5c0 100644
--- a/app/assets/javascripts/runner/components/runner_edit_button.vue
+++ b/app/assets/javascripts/runner/components/runner_edit_button.vue
@@ -1,8 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-const I18N_EDIT = __('Edit');
+import { I18N_EDIT } from '../constants';
export default {
components: {
diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue
index c13e7e90168..eb77babcc57 100644
--- a/app/assets/javascripts/runner/components/runner_jobs.vue
+++ b/app/assets/javascripts/runner/components/runner_jobs.vue
@@ -1,7 +1,7 @@
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import getRunnerJobsQuery from '../graphql/get_runner_jobs.query.graphql';
+import runnerJobsQuery from '../graphql/details/runner_jobs.query.graphql';
import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants';
import { captureException } from '../sentry_utils';
import { getPaginationVariables } from '../utils';
@@ -34,7 +34,7 @@ export default {
},
apollo: {
jobs: {
- query: getRunnerJobsQuery,
+ query: runnerJobsQuery,
variables() {
return this.variables;
},
@@ -46,7 +46,7 @@ export default {
},
error(error) {
createAlert({ message: I18N_FETCH_ERROR });
- this.reportToSentry(error);
+ captureException({ error, component: this.$options.name });
},
},
},
@@ -62,11 +62,6 @@ export default {
return this.$apollo.queries.jobs.loading;
},
},
- methods: {
- reportToSentry(error) {
- captureException({ error, component: this.$options.name });
- },
- },
I18N_NO_JOBS_FOUND,
};
</script>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index bb36882d3ae..51749b0255f 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -1,22 +1,20 @@
<script>
-import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
+import { GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { formatJobCount, tableField } from '../utils';
-import RunnerActionsCell from './cells/runner_actions_cell.vue';
import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue';
export default {
components: {
- GlTable,
+ GlTableLite,
GlSkeletonLoader,
TooltipOnTruncate,
TimeAgo,
- RunnerActionsCell,
RunnerSummaryCell,
RunnerTags,
RunnerStatusCell,
@@ -35,6 +33,16 @@ export default {
required: true,
},
},
+ computed: {
+ tableClass() {
+ // <gl-table-lite> does not provide a busy state, add
+ // simple support for it.
+ // See http://bootstrap-vue.org/docs/components/table#table-busy-state
+ return {
+ 'gl-opacity-6': this.loading,
+ };
+ },
+ },
methods: {
formatJobCount(jobCount) {
return formatJobCount(jobCount);
@@ -62,8 +70,9 @@ export default {
</script>
<template>
<div>
- <gl-table
- :busy="loading"
+ <gl-table-lite
+ :aria-busy="loading"
+ :class="tableClass"
:items="runners"
:fields="$options.fields"
:tbody-tr-attr="runnerTrAttr"
@@ -72,10 +81,6 @@ export default {
primary-key="id"
fixed
>
- <template v-if="!runners.length" #table-busy>
- <gl-skeleton-loader v-for="i in 4" :key="i" />
- </template>
-
<template #cell(status)="{ item }">
<runner-status-cell :runner="item" />
</template>
@@ -114,8 +119,12 @@ export default {
</template>
<template #cell(actions)="{ item }">
- <runner-actions-cell :runner="item" />
+ <slot name="runner-actions-cell" :runner="item"></slot>
</template>
- </gl-table>
+ </gl-table-lite>
+
+ <template v-if="!runners.length && loading">
+ <gl-skeleton-loader v-for="i in 4" :key="i" />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue
index a8b259f5b90..c88634bfbd9 100644
--- a/app/assets/javascripts/runner/components/runner_pause_button.vue
+++ b/app/assets/javascripts/runner/components/runner_pause_button.vue
@@ -1,9 +1,9 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql';
+import runnerToggleActiveMutation from '~/runner/graphql/shared/runner_toggle_active.mutation.graphql';
import { createAlert } from '~/flash';
import { captureException } from '~/runner/sentry_utils';
-import { I18N_PAUSE, I18N_RESUME } from '../constants';
+import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants';
export default {
name: 'RunnerPauseButton',
@@ -52,11 +52,10 @@ export default {
return null;
},
tooltip() {
- // Only show tooltip when compact.
- // Also prevent a "sticky" tooltip: If this button is
- // disabled, mouseout listeners don't run leaving the tooltip stuck
- if (this.compact && !this.updating) {
- return this.label;
+ // Prevent a "sticky" tooltip: If this button is disabled,
+ // mouseout listeners don't run leaving the tooltip stuck
+ if (!this.updating) {
+ return this.isActive ? I18N_PAUSE_TOOLTIP : I18N_RESUME_TOOLTIP;
}
return '';
},
@@ -92,11 +91,8 @@ export default {
},
onError(error) {
const { message } = error;
- createAlert({ message });
- this.reportToSentry(error);
- },
- reportToSentry(error) {
+ createAlert({ message });
captureException({ error, component: this.$options.name });
},
},
@@ -105,7 +101,7 @@ export default {
<template>
<gl-button
- v-gl-tooltip.hover.viewport="tooltip"
+ v-gl-tooltip="tooltip"
v-bind="$attrs"
:aria-label="ariaLabel"
:icon="icon"
diff --git a/app/assets/javascripts/runner/components/runner_paused_badge.vue b/app/assets/javascripts/runner/components/runner_paused_badge.vue
index d1e6fa05e4d..27618290ce6 100644
--- a/app/assets/javascripts/runner/components/runner_paused_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_paused_badge.vue
@@ -1,6 +1,6 @@
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { I18N_PAUSED_RUNNER_DESCRIPTION } from '../constants';
+import { I18N_PAUSED_DESCRIPTION } from '../constants';
export default {
components: {
@@ -9,17 +9,11 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- i18n: {
- I18N_PAUSED_RUNNER_DESCRIPTION,
- },
+ I18N_PAUSED_DESCRIPTION,
};
</script>
<template>
- <gl-badge
- v-gl-tooltip="$options.i18n.I18N_PAUSED_RUNNER_DESCRIPTION"
- variant="danger"
- v-bind="$attrs"
- >
+ <gl-badge v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION" variant="danger" v-bind="$attrs">
{{ s__('Runners|paused') }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue
index c4065a24ff2..f8ec29b8a24 100644
--- a/app/assets/javascripts/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/runner/components/runner_projects.vue
@@ -2,7 +2,7 @@
import { GlSkeletonLoading } from '@gitlab/ui';
import { sprintf, formatNumber } from '~/locale';
import { createAlert } from '~/flash';
-import getRunnerProjectsQuery from '../graphql/get_runner_projects.query.graphql';
+import runnerProjectsQuery from '../graphql/details/runner_projects.query.graphql';
import {
I18N_ASSIGNED_PROJECTS,
I18N_NONE,
@@ -41,7 +41,7 @@ export default {
},
apollo: {
projects: {
- query: getRunnerProjectsQuery,
+ query: runnerProjectsQuery,
variables() {
return this.variables;
},
@@ -55,8 +55,7 @@ export default {
},
error(error) {
createAlert({ message: I18N_FETCH_ERROR });
-
- this.reportToSentry(error);
+ captureException({ error, component: this.$options.name });
},
},
},
@@ -77,11 +76,6 @@ export default {
});
},
},
- methods: {
- reportToSentry(error) {
- captureException({ error, component: this.$options.name });
- },
- },
I18N_NONE,
};
</script>
diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue
index e3deb94236e..e44450a2a8d 100644
--- a/app/assets/javascripts/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/runner/components/runner_update_form.vue
@@ -15,7 +15,7 @@ import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { __ } from '~/locale';
import { captureException } from '~/runner/sentry_utils';
import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
-import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql';
+import runnerUpdateMutation from '../graphql/details/runner_update.mutation.graphql';
export default {
name: 'RunnerUpdateForm',
@@ -82,9 +82,9 @@ export default {
this.onSuccess();
} catch (error) {
const { message } = error;
- createAlert({ message });
- this.reportToSentry(error);
+ createAlert({ message });
+ captureException({ error, component: this.$options.name });
} finally {
this.saving = false;
}
@@ -93,9 +93,6 @@ export default {
createAlert({ message: __('Changes saved.'), variant: VARIANT_SUCCESS });
this.model = runnerToModel(this.runner);
},
- reportToSentry(error) {
- captureException({ error, component: this.$options.name });
- },
},
ACCESS_LEVEL_NOT_PROTECTED,
ACCESS_LEVEL_REF_PROTECTED,
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 1544efaaae2..bd5be2175ad 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -35,12 +35,20 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__(
'Runners|No contact from this runner in over 3 months',
);
-// Active flag
+// Actions
+export const I18N_EDIT = __('Edit');
+
export const I18N_PAUSE = __('Pause');
+export const I18N_PAUSE_TOOLTIP = s__('Runners|Pause from accepting jobs');
+export const I18N_PAUSED_DESCRIPTION = s__('Runners|Not accepting jobs');
+
export const I18N_RESUME = __('Resume');
+export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs');
+
+export const I18N_DELETE_RUNNER = s__('Runners|Delete runner');
+export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
-export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
// Runner details
@@ -91,8 +99,8 @@ export const ACCESS_LEVEL_REF_PROTECTED = 'REF_PROTECTED';
// CiRunnerSort
export const CREATED_DESC = 'CREATED_DESC';
-export const CREATED_ASC = 'CREATED_ASC'; // TODO Add this to the API
-export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API
+export const CREATED_ASC = 'CREATED_ASC';
+export const CONTACTED_DESC = 'CONTACTED_DESC';
export const CONTACTED_ASC = 'CONTACTED_ASC';
export const DEFAULT_SORT = CREATED_DESC;
diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/details/runner.query.graphql
index f6ce8281c64..4792a186160 100644
--- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner.query.graphql
@@ -1,10 +1,9 @@
-#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql"
+#import "ee_else_ce/runner/graphql/details/runner_details.fragment.graphql"
query getRunner($id: CiRunnerID!) {
# We have an id in deeply nested fragment
# eslint-disable-next-line @graphql-eslint/require-id-when-available
runner(id: $id) {
- __typename
...RunnerDetails
}
}
diff --git a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql b/app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql
index 2449ee0fc0f..2449ee0fc0f 100644
--- a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql
index 74760bbaa07..d8c67728fac 100644
--- a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql
@@ -1,4 +1,5 @@
fragment RunnerDetailsShared on CiRunner {
+ __typename
id
runnerType
active
@@ -22,7 +23,7 @@ fragment RunnerDetailsShared on CiRunner {
groups {
# Only a single group can be loaded here, while projects
# are loaded separately using the query with pagination
- # parameters `get_runner_projects.query.graphql`.
+ # parameters `runner_projects.query.graphql`.
nodes {
id
avatarUrl
diff --git a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql
index 2b1decd3ddd..2b1decd3ddd 100644
--- a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql
index f97237b8267..f97237b8267 100644
--- a/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql
index 8d1b75828be..e4bf51e2c30 100644
--- a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql"
+#import "ee_else_ce/runner/graphql/details/runner_details.fragment.graphql"
# Mutation for updates from the runner form, loads
# attributes shown in the runner details.
diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
index ed03a8c34ae..8df4c2fc65c 100644
--- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
@@ -1,4 +1,4 @@
-#import "~/runner/graphql/runner_node.fragment.graphql"
+#import "~/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getRunners(
@@ -24,7 +24,7 @@ query getRunners(
sort: $sort
) {
nodes {
- ...RunnerNode
+ ...ListItem
adminUrl
editAdminUrl
}
diff --git a/app/assets/javascripts/runner/graphql/get_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql
index 181a4495cae..181a4495cae 100644
--- a/app/assets/javascripts/runner/graphql/get_runners_count.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
index 986dd16b992..b517f5e89a8 100644
--- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
@@ -1,4 +1,4 @@
-#import "~/runner/graphql/runner_node.fragment.graphql"
+#import "~/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getGroupRunners(
@@ -27,9 +27,9 @@ query getGroupRunners(
) {
edges {
webUrl
+ editUrl
node {
- __typename
- ...RunnerNode
+ ...ListItem
}
}
pageInfo {
diff --git a/app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql
index 554eb09e372..554eb09e372 100644
--- a/app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql
index fbdef817f2f..620c18c5bc0 100644
--- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql
@@ -1,4 +1,4 @@
-fragment RunnerNode on CiRunner {
+fragment ListItem on CiRunner {
__typename
id
description
diff --git a/app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql b/app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql
index 9c2797732ad..9c2797732ad 100644
--- a/app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql b/app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql
index d580ea2785e..d580ea2785e 100644
--- a/app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql b/app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql
index 9b15570dbc0..9b15570dbc0 100644
--- a/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql
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 c4ee0ad4dfb..35fd7fff6d3 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -12,19 +12,20 @@ import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
+import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import {
- I18N_FETCH_ERROR,
GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE,
PROJECT_TYPE,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_STALE,
+ I18N_FETCH_ERROR,
} from '../constants';
-import getGroupRunnersQuery from '../graphql/get_group_runners.query.graphql';
-import getGroupRunnersCountQuery from '../graphql/get_group_runners_count.query.graphql';
+import groupRunnersQuery from '../graphql/list/group_runners.query.graphql';
+import groupRunnersCountQuery from '../graphql/list/group_runners_count.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
@@ -33,7 +34,7 @@ import {
import { captureException } from '../sentry_utils';
const runnersCountSmartQuery = {
- query: getGroupRunnersCountQuery,
+ query: groupRunnersCountQuery,
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
update(data) {
return data?.group?.runners?.count;
@@ -55,6 +56,7 @@ export default {
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
+ RunnerActionsCell,
},
props: {
registrationToken: {
@@ -74,15 +76,15 @@ export default {
return {
search: fromUrlQueryToSearch(),
runners: {
- webUrls: [],
items: [],
+ urlsById: {},
pageInfo: {},
},
};
},
apollo: {
runners: {
- query: getGroupRunnersQuery,
+ query: groupRunnersQuery,
// Runners can be updated by users directly in this list.
// A "cache and network" policy prevents outdated filtered
// results.
@@ -91,12 +93,23 @@ export default {
return this.variables;
},
update(data) {
- const { runners } = data?.group || {};
+ const { edges = [], pageInfo = {} } = data?.group?.runners || {};
+
+ const items = [];
+ const urlsById = {};
+
+ edges.forEach(({ node, webUrl, editUrl }) => {
+ items.push(node);
+ urlsById[node.id] = {
+ web: webUrl,
+ edit: editUrl,
+ };
+ });
return {
- webUrls: runners?.edges.map(({ webUrl }) => webUrl) || [],
- items: runners?.edges.map(({ node }) => node) || [],
- pageInfo: runners?.pageInfo || {},
+ items,
+ urlsById,
+ pageInfo,
};
},
error(error) {
@@ -190,6 +203,7 @@ export default {
deep: true,
handler() {
// TODO Implement back button reponse using onpopstate
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/333804
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
@@ -221,6 +235,16 @@ export default {
}
return null;
},
+ webUrl(runner) {
+ return this.runners.urlsById[runner.id]?.web;
+ },
+ editUrl(runner) {
+ return this.runners.urlsById[runner.id]?.edit;
+ },
+ onDeleted({ message }) {
+ this.$root.$toast?.show(message);
+ this.$apollo.queries.runners.refetch();
+ },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
@@ -272,13 +296,20 @@ export default {
</div>
<template v-else>
<runner-list :runners="runners.items" :loading="runnersLoading">
- <template #runner-name="{ runner, index }">
- <gl-link :href="runners.webUrls[index]">
+ <template #runner-name="{ runner }">
+ <gl-link :href="webUrl(runner)">
<runner-name :runner="runner" />
</gl-link>
</template>
+ <template #runner-actions-cell="{ runner }">
+ <runner-actions-cell :runner="runner" :edit-url="editUrl(runner)" @deleted="onDeleted" />
+ </template>
</runner-list>
- <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
+ <runner-pagination
+ v-model="search.pagination"
+ class="gl-mt-3"
+ :page-info="runners.pageInfo"
+ />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue
index 65114ee066e..f27dae8249d 100644
--- a/app/assets/javascripts/search/topbar/components/app.vue
+++ b/app/assets/javascripts/search/topbar/components/app.vue
@@ -1,17 +1,20 @@
<script>
-import { GlForm, GlSearchBoxByType, GlButton } from '@gitlab/ui';
+import { GlSearchBoxByClick } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
+import { s__ } from '~/locale';
import GroupFilter from './group_filter.vue';
import ProjectFilter from './project_filter.vue';
export default {
name: 'GlobalSearchTopbar',
+ i18n: {
+ searchPlaceholder: s__(`GlobalSearch|Search for projects, issues, etc.`),
+ searchLabel: s__(`GlobalSearch|What are you searching for?`),
+ },
components: {
- GlForm,
- GlSearchBoxByType,
+ GlSearchBoxByClick,
GroupFilter,
ProjectFilter,
- GlButton,
},
props: {
groupInitialData: {
@@ -49,28 +52,24 @@ export default {
</script>
<template>
- <gl-form class="search-page-form" @submit.prevent="applyQuery">
- <section class="gl-lg-display-flex gl-align-items-flex-end">
- <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
- <label>{{ __('What are you searching for?') }}</label>
- <gl-search-box-by-type
- id="dashboard_search"
- v-model="search"
- name="search"
- :placeholder="__(`Search for projects, issues, etc.`)"
- />
- </div>
- <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
- <label class="gl-display-block">{{ __('Group') }}</label>
- <group-filter :initial-data="groupInitialData" />
- </div>
- <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
- <label class="gl-display-block">{{ __('Project') }}</label>
- <project-filter :initial-data="projectInitialData" />
- </div>
- <gl-button class="btn-search gl-lg-ml-2" category="primary" variant="confirm" type="submit"
- >{{ __('Search') }}
- </gl-button>
- </section>
- </gl-form>
+ <section class="search-page-form gl-lg-display-flex gl-align-items-flex-end">
+ <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
+ <label>{{ $options.i18n.searchLabel }}</label>
+ <gl-search-box-by-click
+ id="dashboard_search"
+ v-model="search"
+ name="search"
+ :placeholder="$options.i18n.searchPlaceholder"
+ @submit="applyQuery"
+ />
+ </div>
+ <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
+ <label class="gl-display-block">{{ __('Group') }}</label>
+ <group-filter :initial-data="groupInitialData" />
+ </div>
+ <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
+ <label class="gl-display-block">{{ __('Project') }}</label>
+ <project-filter :initial-data="projectInitialData" />
+ </div>
+ </section>
</template>
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 81d222438e3..39a2939f52a 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -16,6 +16,8 @@ import {
REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
+import kontraLogo from 'images/vulnerability/kontra-logo.svg';
+import scwLogo from 'images/vulnerability/scw-logo.svg';
import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql';
import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql';
@@ -222,14 +224,12 @@ export const securityFeatures = [
helpPath: COVERAGE_FUZZING_HELP_PATH,
configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH,
type: REPORT_TYPE_COVERAGE_FUZZING,
- secondary: gon?.features?.corpusManagementUi
- ? {
- type: REPORT_TYPE_CORPUS_MANAGEMENT,
- name: CORPUS_MANAGEMENT_NAME,
- description: CORPUS_MANAGEMENT_DESCRIPTION,
- configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT,
- }
- : {},
+ secondary: {
+ type: REPORT_TYPE_CORPUS_MANAGEMENT,
+ name: CORPUS_MANAGEMENT_NAME,
+ description: CORPUS_MANAGEMENT_DESCRIPTION,
+ configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT,
+ },
},
];
@@ -281,3 +281,21 @@ export const featureToMutationMap = {
export const AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY =
'security_configuration_auto_devops_enabled_dismissed_projects';
+
+// Fetch the svg path from the GraphQL query once this issue is resolved
+// https://gitlab.com/gitlab-org/gitlab/-/issues/346899
+export const TEMP_PROVIDER_LOGOS = {
+ Kontra: {
+ svg: kontraLogo,
+ },
+ [__('Secure Code Warrior')]: {
+ svg: scwLogo,
+ },
+};
+
+// Use the `url` field from the GraphQL query once this issue is resolved
+// https://gitlab.com/gitlab-org/gitlab/-/issues/356129
+export const TEMP_PROVIDER_URLS = {
+ Kontra: 'https://application.security/',
+ [__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/',
+};
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 1c37d8008de..cd5ad86e1a8 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -31,13 +31,12 @@ export default {
const button = this.enabled
? {
text: this.$options.i18n.configureFeature,
- category: 'secondary',
}
: {
text: this.$options.i18n.enableFeature,
- category: 'primary',
};
+ button.category = 'secondary';
button.text = sprintf(button.text, { feature: this.shortName });
return button;
@@ -126,7 +125,7 @@ export default {
v-else-if="showManageViaMr"
:feature="feature"
variant="confirm"
- category="primary"
+ category="secondary"
class="gl-mt-5"
:data-qa-selector="`${feature.type}_mr_button`"
@error="onError"
diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
index 539e2bff17c..bb540303cfd 100644
--- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -1,15 +1,31 @@
<script>
-import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlTooltipDirective,
+ GlCard,
+ GlToggle,
+ GlLink,
+ GlSkeletonLoader,
+ GlIcon,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import Tracking from '~/tracking';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import {
TRACK_TOGGLE_TRAINING_PROVIDER_ACTION,
TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
+ TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION,
+ TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL,
} from '~/security_configuration/constants';
import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
-import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql';
-import configureSecurityTrainingProvidersMutation from '../graphql/configure_security_training_providers.mutation.graphql';
+import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql';
+import configureSecurityTrainingProvidersMutation from '~/security_configuration/graphql/configure_security_training_providers.mutation.graphql';
+import {
+ updateSecurityTrainingCache,
+ updateSecurityTrainingOptimisticResponse,
+} from '~/security_configuration/graphql/cache_utils';
+import { TEMP_PROVIDER_LOGOS, TEMP_PROVIDER_URLS } from './constants';
const i18n = {
providerQueryErrorMessage: __(
@@ -18,6 +34,10 @@ const i18n = {
configMutationErrorMessage: __(
'Could not save configuration. Please refresh the page, or try again later.',
),
+ primaryTraining: s__('SecurityTraining|Primary Training'),
+ primaryTrainingDescription: s__(
+ 'SecurityTraining|Training from this partner takes precedence when more than one training partner is enabled.',
+ ),
};
export default {
@@ -27,6 +47,11 @@ export default {
GlToggle,
GlLink,
GlSkeletonLoader,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
mixins: [Tracking.mixin()],
inject: ['projectFullPath'],
@@ -49,12 +74,14 @@ export default {
data() {
return {
errorMessage: '',
- providerLoadingId: null,
securityTrainingProviders: [],
hasTouchedConfiguration: false,
};
},
computed: {
+ enabledProviders() {
+ return this.securityTrainingProviders.filter(({ isEnabled }) => isEnabled);
+ },
isLoading() {
return this.$apollo.queries.securityTrainingProviders.loading;
},
@@ -89,15 +116,41 @@ export default {
Sentry.captureException(e);
}
},
- toggleProvider(provider) {
- const { isEnabled } = provider;
+ async toggleProvider(provider) {
+ const { isEnabled, isPrimary } = provider;
const toggledIsEnabled = !isEnabled;
this.trackProviderToggle(provider.id, toggledIsEnabled);
- this.storeProvider({ ...provider, isEnabled: toggledIsEnabled });
+
+ // when the current primary provider gets disabled then set the first enabled to be the new primary
+ if (!toggledIsEnabled && isPrimary && this.enabledProviders.length > 1) {
+ const firstOtherEnabledProvider = this.enabledProviders.find(
+ ({ id }) => id !== provider.id,
+ );
+ this.setPrimaryProvider(firstOtherEnabledProvider);
+ }
+
+ this.storeProvider({
+ ...provider,
+ isEnabled: toggledIsEnabled,
+ });
},
- async storeProvider({ id, isEnabled, isPrimary }) {
- this.providerLoadingId = id;
+ setPrimaryProvider(provider) {
+ this.storeProvider({ ...provider, isPrimary: true });
+ },
+ async storeProvider(provider) {
+ const { id, isEnabled, isPrimary } = provider;
+ let nextIsPrimary = isPrimary;
+
+ // if the current provider has been disabled it can't be primary
+ if (!isEnabled) {
+ nextIsPrimary = false;
+ }
+
+ // if the current provider is the only enabled provider it should be primary
+ if (isEnabled && !this.enabledProviders.length) {
+ nextIsPrimary = true;
+ }
try {
const {
@@ -111,9 +164,18 @@ export default {
projectPath: this.projectFullPath,
providerId: id,
isEnabled,
- isPrimary,
+ isPrimary: nextIsPrimary,
},
},
+ optimisticResponse: updateSecurityTrainingOptimisticResponse({
+ id,
+ isEnabled,
+ isPrimary: nextIsPrimary,
+ }),
+ update: updateSecurityTrainingCache({
+ query: securityTrainingProvidersQuery,
+ variables: { fullPath: this.projectFullPath },
+ }),
});
if (errors.length > 0) {
@@ -124,8 +186,6 @@ export default {
this.hasTouchedConfiguration = true;
} catch {
this.errorMessage = this.$options.i18n.configMutationErrorMessage;
- } finally {
- this.providerLoadingId = null;
}
},
trackProviderToggle(providerId, providerIsEnabled) {
@@ -137,8 +197,16 @@ export default {
},
});
},
+ trackProviderLearnMoreClick(providerId) {
+ this.track(TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION, {
+ label: TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL,
+ property: providerId,
+ });
+ },
},
i18n,
+ TEMP_PROVIDER_LOGOS,
+ TEMP_PROVIDER_URLS,
};
</script>
@@ -165,15 +233,54 @@ export default {
:value="provider.isEnabled"
:label="__('Training mode')"
label-position="hidden"
- :is-loading="providerLoadingId === provider.id"
@change="toggleProvider(provider)"
/>
- <div class="gl-ml-5">
+ <div v-if="$options.TEMP_PROVIDER_LOGOS[provider.name]" class="gl-ml-4">
+ <div
+ v-safe-html="$options.TEMP_PROVIDER_LOGOS[provider.name].svg"
+ data-testid="provider-logo"
+ style="width: 18px"
+ role="presentation"
+ ></div>
+ </div>
+ <div class="gl-ml-3">
<h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3>
<p>
{{ provider.description }}
- <gl-link :href="provider.url" target="_blank">{{ __('Learn more.') }}</gl-link>
+ <gl-link
+ v-if="$options.TEMP_PROVIDER_URLS[provider.name]"
+ :href="$options.TEMP_PROVIDER_URLS[provider.name]"
+ target="_blank"
+ @click="trackProviderLearnMoreClick(provider.id)"
+ >
+ {{ __('Learn more.') }}
+ </gl-link>
</p>
+ <!-- Note: The following `div` and it's content will be replaced by 'GlFormRadio' once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1720#note_857342988 is resolved -->
+ <div
+ class="gl-form-radio custom-control custom-radio"
+ data-testid="primary-provider-radio"
+ >
+ <input
+ :id="`security-training-provider-${provider.id}`"
+ type="radio"
+ :checked="provider.isPrimary"
+ class="custom-control-input"
+ :disabled="!provider.isEnabled"
+ @change="setPrimaryProvider(provider)"
+ />
+ <label
+ class="custom-control-label"
+ :for="`security-training-provider-${provider.id}`"
+ >
+ {{ $options.i18n.primaryTraining }}
+ </label>
+ <gl-icon
+ v-gl-tooltip="$options.i18n.primaryTrainingDescription"
+ name="information-o"
+ class="gl-ml-2 gl-cursor-help"
+ />
+ </div>
</div>
</div>
</gl-card>
diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js
index dc76436e91d..14eb10ac2aa 100644
--- a/app/assets/javascripts/security_configuration/constants.js
+++ b/app/assets/javascripts/security_configuration/constants.js
@@ -1,2 +1,8 @@
export const TRACK_TOGGLE_TRAINING_PROVIDER_ACTION = 'toggle_security_training_provider';
export const TRACK_TOGGLE_TRAINING_PROVIDER_LABEL = 'update_security_training_provider';
+export const TRACK_CLICK_TRAINING_LINK_ACTION = 'click_security_training_link';
+export const TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION = 'click_link';
+export const TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL = 'security_training_provider';
+export const TRACK_TRAINING_LOADED_ACTION = 'security_training_link_loaded';
+export const TRACK_PROMOTION_BANNER_CTA_CLICK_ACTION = 'click_button';
+export const TRACK_PROMOTION_BANNER_CTA_CLICK_LABEL = 'security_training_promotion_cta';
diff --git a/app/assets/javascripts/security_configuration/graphql/cache_utils.js b/app/assets/javascripts/security_configuration/graphql/cache_utils.js
new file mode 100644
index 00000000000..6d5258b01dc
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/graphql/cache_utils.js
@@ -0,0 +1,40 @@
+import produce from 'immer';
+
+export const updateSecurityTrainingOptimisticResponse = (changes) => ({
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ securityTrainingUpdate: {
+ __typename: 'SecurityTrainingUpdatePayload',
+ training: {
+ __typename: 'ProjectSecurityTraining',
+ ...changes,
+ },
+ errors: [],
+ },
+});
+
+export const updateSecurityTrainingCache = ({ query, variables }) => (cache, { data }) => {
+ const {
+ securityTrainingUpdate: { training: updatedProvider },
+ } = data;
+ const { project } = cache.readQuery({ query, variables });
+ if (!updatedProvider.isPrimary) {
+ return;
+ }
+
+ // when we set a new primary provider, we need to unset the previous one(s)
+ const updatedProject = produce(project, (draft) => {
+ draft.securityTrainingProviders.forEach((provider) => {
+ // eslint-disable-next-line no-param-reassign
+ provider.isPrimary = provider.id === updatedProvider.id;
+ });
+ });
+
+ // write to the cache
+ cache.writeQuery({
+ query,
+ variables,
+ data: { project: updatedProject },
+ });
+};
diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql
new file mode 100644
index 00000000000..f0474614dab
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql
@@ -0,0 +1,10 @@
+query getSecurityTrainingUrls($projectFullPath: ID!, $identifierExternalIds: [String!]!) {
+ project(fullPath: $projectFullPath) {
+ id
+ securityTrainingUrls(identifierExternalIds: $identifierExternalIds) {
+ name
+ status
+ url
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
index da9ff407faf..240e12ee597 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
+import { IssuableType } from '~/issues/constants';
import { __, sprintf } from '~/locale';
export default {
@@ -31,10 +32,11 @@ export default {
);
},
isMergeRequest() {
- return this.issuableType === 'merge_request';
+ return this.issuableType === IssuableType.MergeRequest;
},
hasMergeIcon() {
- return this.isMergeRequest && !this.user.can_merge;
+ const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
+ return this.isMergeRequest && !canMerge;
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index 2a237e7ace0..578c344da02 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { IssuableType } from '~/issues/constants';
import { __ } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import AssigneeAvatar from './assignee_avatar.vue';
@@ -71,7 +72,8 @@ export default {
},
computed: {
cannotMerge() {
- return this.issuableType === 'merge_request' && !this.user.can_merge;
+ const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
+ return this.issuableType === IssuableType.MergeRequest && !canMerge;
},
tooltipTitle() {
const { name = '', availability = '' } = this.user;
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index 6a74ab83c22..856687c00ae 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -58,7 +58,7 @@ export default {
return this.users.length > 2;
},
allAssigneesCanMerge() {
- return this.users.every((user) => user.can_merge);
+ return this.users.every((user) => user.can_merge || user.mergeRequestInteraction?.canMerge);
},
sidebarAvatarCounter() {
if (this.users.length > DEFAULT_MAX_COUNTER) {
@@ -77,7 +77,9 @@ export default {
return '';
}
- const mergeLength = this.users.filter((u) => u.can_merge).length;
+ const mergeLength = this.users.filter(
+ (u) => u.can_merge || u.mergeRequestInteraction?.canMerge,
+ ).length;
if (mergeLength === this.users.length) {
return '';
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index a3379784bc1..59a4eb54bbe 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -44,7 +44,7 @@ export default {
<div class="gl-display-flex gl-flex-direction-column issuable-assignees">
<div
v-if="emptyUsers"
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed"
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 hide-collapsed"
data-testid="none"
>
<span> {{ __('None') }}</span>
@@ -65,7 +65,7 @@ export default {
v-else
:users="users"
:issuable-type="issuableType"
- class="gl-text-gray-800 gl-mt-2 hide-collapsed"
+ class="gl-text-gray-800 hide-collapsed"
@toggle-attention-requested="toggleAttentionRequested"
/>
</div>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 453dd1b0580..e596d6292bf 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -63,7 +63,7 @@ export default {
computed: {
shouldEnableRealtime() {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
- return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue';
+ return this.issuableType === 'issue';
},
queryVariables() {
return {
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 18654b73ab3..7743004a293 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -1,6 +1,5 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
-import { cloneDeep } from 'lodash';
import Vue from 'vue';
import createFlash from '~/flash';
import { IssuableType } from '~/issues/constants';
@@ -101,7 +100,10 @@ export default {
}
const issuable = data.workspace?.issuable;
if (issuable) {
- this.selected = cloneDeep(issuable.assignees.nodes);
+ this.selected = issuable.assignees.nodes.map((node) => ({
+ ...node,
+ canMerge: node.mergeRequestInteraction?.canMerge || false,
+ }));
}
},
error() {
@@ -112,7 +114,7 @@ export default {
computed: {
shouldEnableRealtime() {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
- return this.glFeatures.realTimeIssueSidebar && this.issuableType === IssuableType.Issue;
+ return this.issuableType === IssuableType.Issue;
},
queryVariables() {
return {
@@ -141,6 +143,7 @@ export default {
username: gon?.current_username,
name: gon?.current_user_fullname,
avatarUrl: gon?.current_user_avatar_url,
+ canMerge: this.issuable?.userPermissions?.canMerge || false,
};
},
signedIn() {
@@ -206,8 +209,8 @@ export default {
expandWidget() {
this.$refs.toggle.expand();
},
- focusSearch() {
- this.$refs.userSelect.focusSearch();
+ showDropdown() {
+ this.$refs.userSelect.showDropdown();
},
showError() {
createFlash({ message: __('An error occurred while fetching participants.') });
@@ -236,11 +239,11 @@ export default {
:initial-loading="isAssigneesLoading"
:title="assigneeText"
:is-dirty="isDirty"
- @open="focusSearch"
+ @open="showDropdown"
@close="saveAssignees"
>
<template #collapsed>
- <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot>
+ <slot name="collapsed" :users="assignees"></slot>
<issuable-assignees
:users="assignees"
:issuable-type="issuableType"
@@ -256,12 +259,13 @@ export default {
:text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo"
:iid="iid"
+ :issuable-id="issuableId"
:full-path="fullPath"
:allow-multiple-assignees="allowMultipleAssignees"
:current-user="currentUser"
:issuable-type="issuableType"
:is-editing="edit"
- class="gl-w-full dropdown-menu-user"
+ class="gl-w-full dropdown-menu-user gl-mt-n3"
@toggle="collapseWidget"
@error="showError"
@input="setDirtyState"
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
index 8ef65ef7308..28bc5afc1a4 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
@@ -30,6 +30,6 @@ export default {
:event="$options.dataTrackEvent"
:label="$options.dataTrackLabel"
:trigger-source="triggerSource"
- classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!"
+ classes="gl-display-block gl-pl-0 gl-hover-text-decoration-none gl-hover-text-blue-800!"
/>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
index e2a38a100b9..19f588b28be 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -1,17 +1,24 @@
<script>
-import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
+import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui';
+import { IssuableType } from '~/issues/constants';
import { s__, sprintf } from '~/locale';
export default {
components: {
GlAvatarLabeled,
GlAvatarLink,
+ GlIcon,
},
props: {
user: {
type: Object,
required: true,
},
+ issuableType: {
+ type: String,
+ required: false,
+ default: IssuableType.Issue,
+ },
},
computed: {
userLabel() {
@@ -22,6 +29,9 @@ export default {
author: this.user.name,
});
},
+ hasCannotMergeIcon() {
+ return this.issuableType === IssuableType.MergeRequest && !this.user.canMerge;
+ },
},
};
</script>
@@ -31,9 +41,19 @@ export default {
<gl-avatar-labeled
:size="32"
:label="userLabel"
- :sub-label="user.username"
+ :sub-label="`@${user.username}`"
:src="user.avatarUrl || user.avatar || user.avatar_url"
- class="gl-align-items-center"
- />
+ class="gl-align-items-center gl-relative"
+ >
+ <template #meta>
+ <gl-icon
+ v-if="hasCannotMergeIcon"
+ name="warning-solid"
+ aria-hidden="true"
+ class="merge-icon"
+ :size="12"
+ />
+ </template>
+ </gl-avatar-labeled>
</gl-avatar-link>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index a27dbee31ec..558fe8ca2aa 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -114,7 +114,7 @@ export default {
class="gl-display-inline-block"
>
<attention-requested-toggle
- v-if="showVerticalList && user.can_update_merge_request"
+ v-if="showVerticalList"
:user="user"
type="assignee"
@toggle-attention-requested="toggleAttentionRequested"
diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
index 42e56906e2c..6ba88939373 100644
--- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
+++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
@@ -8,6 +8,8 @@ export default {
attentionRequestedReviewer: __('Request attention to review'),
attentionRequestedAssignee: __('Request attention'),
removeAttentionRequested: __('Remove attention request'),
+ attentionRequestedNoPermission: __('Attention requested'),
+ noAttentionRequestedNoPermission: __('No attention request'),
},
components: {
GlButton,
@@ -33,17 +35,25 @@ export default {
computed: {
tooltipTitle() {
if (this.user.attention_requested) {
- return this.$options.i18n.removeAttentionRequested;
+ if (this.user.can_update_merge_request) {
+ return this.$options.i18n.removeAttentionRequested;
+ }
+
+ return this.$options.i18n.attentionRequestedNoPermission;
+ }
+
+ if (this.user.can_update_merge_request) {
+ return this.type === 'reviewer'
+ ? this.$options.i18n.attentionRequestedReviewer
+ : this.$options.i18n.attentionRequestedAssignee;
}
- return this.type === 'reviewer'
- ? this.$options.i18n.attentionRequestedReviewer
- : this.$options.i18n.attentionRequestedAssignee;
+ return this.$options.i18n.noAttentionRequestedNoPermission;
},
},
methods: {
toggleAttentionRequired() {
- if (this.loading) return;
+ if (this.loading || !this.user.can_update_merge_request) return;
this.$root.$emit(BV_HIDE_TOOLTIP);
this.loading = true;
@@ -60,12 +70,16 @@ export default {
</script>
<template>
- <span v-gl-tooltip.left.viewport="tooltipTitle">
+ <span
+ v-gl-tooltip.left.viewport="tooltipTitle"
+ class="gl-display-inline-block js-attention-request-toggle"
+ >
<gl-button
:loading="loading"
:variant="user.attention_requested ? 'warning' : 'default'"
:icon="user.attention_requested ? 'attention-solid' : 'attention'"
:aria-label="tooltipTitle"
+ :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }"
size="small"
category="tertiary"
@click="toggleAttentionRequired"
diff --git a/app/assets/javascripts/sidebar/components/incidents/constants.js b/app/assets/javascripts/sidebar/components/incidents/constants.js
new file mode 100644
index 00000000000..cd05a6099fd
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/incidents/constants.js
@@ -0,0 +1,25 @@
+import { s__ } from '~/locale';
+
+export const STATUS_TRIGGERED = 'TRIGGERED';
+export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED';
+export const STATUS_RESOLVED = 'RESOLVED';
+
+export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered');
+export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged');
+export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved');
+
+export const STATUS_LABELS = {
+ [STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL,
+ [STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL,
+ [STATUS_RESOLVED]: STATUS_RESOLVED_LABEL,
+};
+
+export const i18n = {
+ fetchError: s__(
+ 'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.',
+ ),
+ title: s__('IncidentManagement|Status'),
+ updateError: s__(
+ 'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.',
+ ),
+};
diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
new file mode 100644
index 00000000000..2c32cf89387
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { i18n, STATUS_ACKNOWLEDGED, STATUS_TRIGGERED, STATUS_RESOLVED } from './constants';
+import { getStatusLabel } from './utils';
+
+const STATUS_LIST = [STATUS_TRIGGERED, STATUS_ACKNOWLEDGED, STATUS_RESOLVED];
+
+export default {
+ i18n,
+ STATUS_LIST,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: null,
+ validator(value) {
+ return [...STATUS_LIST, null].includes(value);
+ },
+ },
+ },
+ computed: {
+ currentStatusLabel() {
+ return this.getStatusLabel(this.value);
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.dropdown.show();
+ },
+ hide() {
+ this.$refs.dropdown.hide();
+ },
+ getStatusLabel,
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ block
+ :text="currentStatusLabel"
+ toggle-class="dropdown-menu-toggle gl-mb-2"
+ >
+ <slot name="header"> </slot>
+ <gl-dropdown-item
+ v-for="status in $options.STATUS_LIST"
+ :key="status"
+ data-testid="status-dropdown-item"
+ :is-check-item="true"
+ :is-checked="status === value"
+ @click="$emit('input', status)"
+ >
+ {{ getStatusLabel(status) }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
new file mode 100644
index 00000000000..67ae1e6fcab
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
@@ -0,0 +1,135 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants';
+import { createAlert } from '~/flash';
+import { logError } from '~/lib/logger';
+import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue';
+import SidebarEditableItem from '../sidebar_editable_item.vue';
+import { i18n } from './constants';
+import { getStatusLabel } from './utils';
+
+export default {
+ i18n,
+ components: {
+ EscalationStatus,
+ SidebarEditableItem,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ status: null,
+ isUpdating: false,
+ };
+ },
+ apollo: {
+ status: {
+ query() {
+ return escalationStatusQuery;
+ },
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.iid,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.escalationStatus;
+ },
+ error(error) {
+ const message = this.$options.i18n.fetchError;
+ createAlert({ message });
+ logError(message, error);
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.status.loading;
+ },
+ currentStatusLabel() {
+ return getStatusLabel(this.status);
+ },
+ tooltipText() {
+ return `${this.$options.i18n.title}: ${this.currentStatusLabel}`;
+ },
+ },
+ methods: {
+ updateStatus(status) {
+ this.isUpdating = true;
+ this.closeSidebar();
+ return this.$apollo
+ .mutate({
+ mutation: escalationStatusMutation,
+ variables: {
+ status,
+ iid: this.iid,
+ projectPath: this.projectPath,
+ },
+ })
+ .then(({ data: { issueSetEscalationStatus } }) => {
+ this.status = issueSetEscalationStatus.issue.escalationStatus;
+ })
+ .catch((error) => {
+ const message = this.$options.i18n.updateError;
+ createAlert({ message });
+ logError(message, error);
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ closeSidebar() {
+ this.close();
+ this.$refs.editable.collapse();
+ },
+ open() {
+ this.$refs.escalationStatus.show();
+ },
+ close() {
+ this.$refs.escalationStatus.hide();
+ },
+ },
+};
+</script>
+
+<template>
+ <sidebar-editable-item
+ ref="editable"
+ :title="$options.i18n.title"
+ :initial-loading="isLoading"
+ :loading="isUpdating"
+ @open="open"
+ @close="close"
+ >
+ <template #default>
+ <escalation-status ref="escalationStatus" :value="status" @input="updateStatus" />
+ </template>
+ <template #collapsed>
+ <div
+ v-gl-tooltip.viewport.left="tooltipText"
+ class="sidebar-collapsed-icon"
+ data-testid="status-icon"
+ >
+ <gl-icon name="status" :size="16" />
+ </div>
+ <span class="hide-collapsed text-secondary">{{ currentStatusLabel }}</span>
+ </template>
+ </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/incidents/utils.js b/app/assets/javascripts/sidebar/components/incidents/utils.js
new file mode 100644
index 00000000000..59bf1ea466c
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/incidents/utils.js
@@ -0,0 +1,5 @@
+import { s__ } from '~/locale';
+
+import { STATUS_LABELS } from './constants';
+
+export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None');
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index adaf1b65f3f..9485802d3da 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -98,7 +98,7 @@ export default {
data-testid="reviewer"
>
<attention-requested-toggle
- v-if="glFeatures.mrAttentionRequests && user.can_update_merge_request"
+ v-if="glFeatures.mrAttentionRequests"
:user="user"
type="reviewer"
@toggle-attention-requested="toggleAttentionRequested"
diff --git a/app/assets/javascripts/sidebar/components/severity/severity.vue b/app/assets/javascripts/sidebar/components/severity/severity.vue
index 7e7d62256c9..0db856543d0 100644
--- a/app/assets/javascripts/sidebar/components/severity/severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/severity.vue
@@ -1,9 +1,11 @@
<script>
import { GlIcon } from '@gitlab/ui';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
GlIcon,
+ TooltipOnTruncate,
},
props: {
severity: {
@@ -30,13 +32,15 @@ export default {
<template>
<div
- class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
+ class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full"
>
<gl-icon
:size="iconSize"
:name="`severity-${severity.icon}`"
:class="[`icon-${severity.icon}`, { 'gl-mr-3': !iconOnly }]"
/>
- <span v-if="!iconOnly">{{ severity.label }}</span>
+ <tooltip-on-truncate v-if="!iconOnly" :title="severity.label" class="gl-text-truncate">{{
+ severity.label
+ }}</tooltip-on-truncate>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 0238fb8e8d5..989dc574bc3 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,7 +1,8 @@
import { s__, sprintf } from '~/locale';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
+import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
+import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql';
import { IssuableType, WorkspaceType } from '~/issues/constants';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql';
import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql';
@@ -49,12 +50,12 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries
import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql';
import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
+import getEscalationStatusQuery from '~/sidebar/queries/escalation_status.query.graphql';
+import updateEscalationStatusMutation from '~/sidebar/queries/update_escalation_status.mutation.graphql';
import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql';
import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql';
import projectMilestonesQuery from './queries/project_milestones.query.graphql';
-export const ASSIGNEES_DEBOUNCE_DELAY = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
-
export const defaultEpicSort = 'TITLE_ASC';
export const epicIidPattern = /^&(?<iid>\d+)$/;
@@ -91,6 +92,15 @@ export const participantsQueries = {
},
};
+export const userSearchQueries = {
+ [IssuableType.Issue]: {
+ query: userSearchQuery,
+ },
+ [IssuableType.MergeRequest]: {
+ query: userSearchWithMRPermissionsQuery,
+ },
+};
+
export const confidentialityQueries = {
[IssuableType.Issue]: {
query: issueConfidentialQuery,
@@ -305,3 +315,6 @@ export function dropdowni18nText(issuableAttribute, issuableType) {
),
};
}
+
+export const escalationStatusQuery = getEscalationStatusQuery;
+export const escalationStatusMutation = updateEscalationStatusMutation;
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index c29784aa328..2a7d967cb61 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -10,6 +10,7 @@ import {
isInIssuePage,
isInDesignPage,
isInIncidentPage,
+ isInMRPage,
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
@@ -27,9 +28,11 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_v
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import eventHub from '~/sidebar/event_hub';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
+import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
@@ -134,6 +137,8 @@ function mountAssigneesComponent() {
if (!el) return;
const { id, iid, fullPath, editable } = getSidebarOptions();
+ const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage();
+ const issuableType = isIssuablePage ? IssuableType.Issue : IssuableType.MergeRequest;
// eslint-disable-next-line no-new
new Vue({
el,
@@ -151,21 +156,16 @@ function mountAssigneesComponent() {
props: {
iid: String(iid),
fullPath,
- issuableType:
- isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? IssuableType.Issue
- : IssuableType.MergeRequest,
+ issuableType,
issuableId: id,
allowMultipleAssignees: !el.dataset.maxAssignees,
},
scopedSlots: {
- collapsed: ({ users, onClick }) =>
+ collapsed: ({ users }) =>
createElement(CollapsedAssigneeList, {
props: {
users,
- },
- nativeOn: {
- click: onClick,
+ issuableType,
},
}),
},
@@ -567,6 +567,36 @@ function mountSeverityComponent() {
});
}
+function mountEscalationStatusComponent() {
+ const statusContainerEl = document.querySelector('#js-escalation-status');
+
+ if (!statusContainerEl) {
+ return false;
+ }
+
+ const { issuableType } = getSidebarOptions();
+ const { canUpdate, issueIid, projectPath } = statusContainerEl.dataset;
+
+ return new Vue({
+ el: statusContainerEl,
+ apolloProvider,
+ components: {
+ SidebarEscalationStatus,
+ },
+ provide: {
+ canUpdate: parseBoolean(canUpdate),
+ },
+ render: (createElement) =>
+ createElement('sidebar-escalation-status', {
+ props: {
+ iid: issueIid,
+ issuableType,
+ projectPath,
+ },
+ }),
+ });
+}
+
function mountCopyEmailComponent() {
const el = document.getElementById('issuable-copy-email');
@@ -584,7 +614,7 @@ function mountCopyEmailComponent() {
}
const isAssigneesWidgetShown =
- (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
+ (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget;
export function mountSidebar(mediator, store) {
initInviteMembersModal();
@@ -618,10 +648,13 @@ export function mountSidebar(mediator, store) {
mountSeverityComponent();
+ mountEscalationStatusComponent();
+
if (window.gon?.features?.mrAttentionRequests) {
- eventHub.$on('removeCurrentUserAttentionRequested', () =>
- mediator.removeCurrentUserAttentionRequested(),
- );
+ eventHub.$on('removeCurrentUserAttentionRequested', () => {
+ mediator.removeCurrentUserAttentionRequested();
+ refreshUserMergeRequestCounts();
+ });
}
}
diff --git a/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql b/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql
new file mode 100644
index 00000000000..cb7c5a0fbe7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql
@@ -0,0 +1,9 @@
+query escalationStatusQuery($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: issue(iid: $iid) {
+ id
+ escalationStatus
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql
new file mode 100644
index 00000000000..a4aff7968df
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql
@@ -0,0 +1,10 @@
+mutation updateEscalationStatus($projectPath: ID!, $status: IssueEscalationStatus!, $iid: String!) {
+ issueSetEscalationStatus(input: { projectPath: $projectPath, status: $status, iid: $iid }) {
+ errors
+ clientMutationId
+ issue {
+ id
+ escalationStatus
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
index 1be670f7590..74ab65e4e04 100644
--- a/app/assets/javascripts/sidebar/sidebar_bundle.js
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -3,7 +3,17 @@ import Mediator from './sidebar_mediator';
export default (store) => {
const mediator = new Mediator(getSidebarOptions());
- mediator.fetch();
+ mediator
+ .fetch()
+ .then(() => {
+ if (window.gon?.features?.mrAttentionRequests) {
+ return import('~/attention_requests');
+ }
+
+ return null;
+ })
+ .then((module) => module?.initSideNavPopover())
+ .catch(() => {});
mountSidebar(mediator, store);
};
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 4664bb56958..83fb8f31dfb 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -2,6 +2,7 @@ import Store from '~/sidebar/stores/sidebar_store';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { visitUrl } from '../lib/utils/url_utility';
import Service from './services/sidebar_service';
@@ -125,6 +126,7 @@ export default class SidebarMediator {
this.store.updateReviewer(user.id, 'attention_requested');
this.store.updateAssignee(user.id, 'attention_requested');
+ refreshUserMergeRequestCounts();
callback();
} catch (error) {
callback();
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index d2841156e55..b7159fd6835 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,6 +1,7 @@
/* eslint-disable consistent-return */
import $ from 'jquery';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { spriteIcon } from '~/lib/utils/common_utils';
import FilesCommentButton from './files_comment_button';
import createFlash from './flash';
@@ -10,7 +11,7 @@ import { __ } from './locale';
import syntaxHighlight from './syntax_highlight';
const WRAPPER = '<div class="diff-content"></div>';
-const LOADING_HTML = '<span class="spinner"></span>';
+const LOADING_HTML = loadingIconForLegacyJS().outerHTML;
const ERROR_HTML = `<div class="nothing-here-block">${spriteIcon(
'warning-solid',
's16',
diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue
index a5a613b7282..fd9177bef3f 100644
--- a/app/assets/javascripts/terraform/components/empty_state.vue
+++ b/app/assets/javascripts/terraform/components/empty_state.vue
@@ -16,7 +16,7 @@ export default {
},
computed: {
docsUrl() {
- return helpPagePath('user/infrastructure/terraform_state');
+ return helpPagePath('user/infrastructure/iac/terraform_state');
},
},
};
diff --git a/app/assets/javascripts/toggle_buttons.js b/app/assets/javascripts/toggle_buttons.js
deleted file mode 100644
index 5b85107991a..00000000000
--- a/app/assets/javascripts/toggle_buttons.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import $ from 'jquery';
-import createFlash from './flash';
-import { parseBoolean } from './lib/utils/common_utils';
-import { __ } from './locale';
-
-/*
- example HAML:
- ```
- %button.js-project-feature-toggle.project-feature-toggle{ type: "button",
- class: "#{'is-checked' if enabled?}",
- 'aria-label': _('Toggle Kubernetes Cluster') }
- %input{ type: "hidden", class: 'js-project-feature-toggle-input', value: enabled? }
- ```
-*/
-
-function updateToggle(toggle, isOn) {
- toggle.classList.toggle('is-checked', isOn);
-}
-
-function onToggleClicked(toggle, input, clickCallback) {
- const previousIsOn = parseBoolean(input.value);
-
- // Visually change the toggle and start loading
- updateToggle(toggle, !previousIsOn);
- toggle.setAttribute('disabled', true);
- toggle.classList.toggle('is-loading', true);
-
- Promise.resolve(clickCallback(!previousIsOn, toggle))
- .then(() => {
- // Actually change the input value
- input.setAttribute('value', !previousIsOn);
- })
- .catch(() => {
- // Revert the visuals if something goes wrong
- updateToggle(toggle, previousIsOn);
- })
- .then(() => {
- // Remove the loading indicator in any case
- toggle.removeAttribute('disabled');
- toggle.classList.toggle('is-loading', false);
-
- $(input).trigger('trigger-change');
- })
- .catch(() => {
- createFlash({
- message: __('Something went wrong when toggling the button'),
- });
- });
-}
-
-export default function setupToggleButtons(container, clickCallback = () => {}) {
- const toggles = container.querySelectorAll('.js-project-feature-toggle');
-
- toggles.forEach((toggle) => {
- const input = toggle.querySelector('.js-project-feature-toggle-input');
- const isOn = parseBoolean(input.value);
-
- // Get the visible toggle in sync with the hidden input
- updateToggle(toggle, isOn);
-
- toggle.addEventListener('click', onToggleClicked.bind(null, toggle, input, clickCallback));
- });
-}
diff --git a/app/assets/javascripts/toggles/index.js b/app/assets/javascripts/toggles/index.js
index 046b9fc7dcd..5848b3a424c 100644
--- a/app/assets/javascripts/toggles/index.js
+++ b/app/assets/javascripts/toggles/index.js
@@ -8,16 +8,12 @@ export const initToggle = (el) => {
return false;
}
- const {
- name,
- isChecked,
- disabled,
- isLoading,
- label,
- help,
- labelPosition,
- ...dataset
- } = el.dataset;
+ const { name, id, isChecked, disabled, isLoading, label, help, labelPosition, ...dataset } =
+ el.dataset || {};
+
+ const dataAttrs = Object.fromEntries(
+ Object.entries(dataset).map(([key, value]) => [`data-${kebabCase(key)}`, value]),
+ );
return new Vue({
el,
@@ -50,9 +46,7 @@ export const initToggle = (el) => {
labelPosition,
},
class: el.className,
- attrs: Object.fromEntries(
- Object.entries(dataset).map(([key, value]) => [`data-${kebabCase(key)}`, value]),
- ),
+ attrs: { id, ...dataAttrs },
on: {
change: (newValue) => {
this.value = newValue;
diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
index bc9d7384ea4..7e596f5f36f 100644
--- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js
+++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
@@ -10,7 +10,8 @@ export function dispatchSnowplowEvent(
throw new Error('Tracking: no category provided for tracking.');
}
- const { label, property, value, extra = {} } = data;
+ const { label, property, extra = {} } = data;
+ let { value } = data;
const standardContext = getStandardContext({ extra });
const contexts = [standardContext];
@@ -19,5 +20,9 @@ export function dispatchSnowplowEvent(
contexts.push(data.context);
}
+ if (value !== undefined) {
+ value = Number(value);
+ }
+
return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
}
diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js
index c26abc261ed..173eef0646b 100644
--- a/app/assets/javascripts/tracking/tracking.js
+++ b/app/assets/javascripts/tracking/tracking.js
@@ -10,6 +10,8 @@ import {
addReferrersCacheEntry,
} from './utils';
+const ALLOWED_URL_HASHES = ['#diff', '#note'];
+
export default class Tracking {
static queuedEvents = [];
static initialized = false;
@@ -183,7 +185,9 @@ export default class Tracking {
originalUrl: window.location.href,
});
- window.snowplow('setCustomUrl', pageLinks.url);
+ const appendHash = ALLOWED_URL_HASHES.some((prefix) => window.location.hash.startsWith(prefix));
+ const customUrl = `${pageUrl}${appendHash ? window.location.hash : ''}`;
+ window.snowplow('setCustomUrl', customUrl);
if (document.referrer) {
const node = referrers.find((links) => links.originalUrl === document.referrer);
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 8ed92e6b948..656c851aa3d 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -210,7 +210,7 @@ function UsersSelect(currentUser, els, options = {}) {
return axios.put(issueURL, data).then(({ data }) => {
let user = {};
- let tooltipTitle = user.name;
+ let tooltipTitle;
$dropdown.trigger('loaded.gl.dropdown');
$loading.addClass('gl-display-none');
if (data.assignee) {
@@ -806,7 +806,9 @@ UsersSelect.prototype.renderRow = function (
</strong>
${
username
- ? `<span class="dropdown-menu-user-username gl-text-gray-400">${username}</span>`
+ ? `<span class="dropdown-menu-user-username gl-text-gray-400">${escape(
+ username,
+ )}</span>`
: ''
}
${this.renderApprovalRules(elsClassName, user.applicable_approval_rules)}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index a25b4ab54e5..684386883c8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -2,21 +2,20 @@
import {
GlButton,
GlLoadingIcon,
- GlLink,
- GlBadge,
GlSafeHtmlDirective,
GlTooltipDirective,
GlIntersectionObserver,
} from '@gitlab/ui';
import { once } from 'lodash';
import * as Sentry from '@sentry/browser';
+import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import api from '~/api';
import { sprintf, s__, __ } from '~/locale';
-import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import Poll from '~/lib/utils/poll';
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
import StatusIcon from './status_icon.vue';
import Actions from './actions.vue';
+import ChildContent from './child_content.vue';
import { generateText } from './utils';
export const LOADING_STATES = {
@@ -30,12 +29,12 @@ export default {
components: {
GlButton,
GlLoadingIcon,
- GlLink,
- GlBadge,
GlIntersectionObserver,
- SmartVirtualList,
StatusIcon,
Actions,
+ ChildContent,
+ DynamicScroller,
+ DynamicScrollerItem,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -188,7 +187,7 @@ export default {
this.fetchFullData(this.$props)
.then((data) => {
this.loadingState = null;
- this.fullData = data;
+ this.fullData = data.map((x, i) => ({ id: i, ...x }));
})
.catch((e) => {
this.loadingState = LOADING_STATES.expandedError;
@@ -196,9 +195,6 @@ export default {
Sentry.captureException(e);
});
},
- isArray(arr) {
- return Array.isArray(arr);
- },
appear(index) {
if (index === this.fullData.length - 1) {
this.showFade = false;
@@ -281,80 +277,33 @@ export default {
<div v-if="isLoadingExpanded" class="report-block-container">
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div>
- <smart-virtual-list
+ <dynamic-scroller
v-else-if="hasFullData"
- :length="fullData.length"
- :remain="20"
- :size="32"
- wtag="ul"
- wclass="report-block-list"
+ :items="fullData"
+ :min-item-size="32"
class="report-block-container gl-px-5 gl-py-0"
>
- <li
- v-for="(data, index) in fullData"
- :key="data.id"
- :class="{
- 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
- }"
- class="gl-py-3 gl-pl-7"
- data-testid="extension-list-item"
- >
- <div class="gl-w-full">
- <div v-if="data.header" class="gl-mb-2">
- <template v-if="isArray(data.header)">
- <component
- :is="headerI === 0 ? 'strong' : 'span'"
- v-for="(header, headerI) in data.header"
- :key="headerI"
- v-safe-html="generateText(header)"
- class="gl-display-block"
- />
- </template>
- <strong v-else v-safe-html="generateText(data.header)"></strong>
- </div>
- <div class="gl-display-flex">
- <status-icon
- v-if="data.icon"
- :icon-name="data.icon.name"
- :size="12"
- class="gl-pl-0"
- />
+ <template #default="{ item, index, active }">
+ <dynamic-scroller-item :item="item" :active="active" :class="{ active }">
+ <div
+ :class="{
+ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
+ }"
+ class="gl-py-3 gl-pl-7"
+ data-testid="extension-list-item"
+ >
<gl-intersection-observer
:options="{ rootMargin: '100px', thresholds: 0.1 }"
class="gl-w-full"
@appear="appear(index)"
@disappear="disappear(index)"
>
- <div class="gl-flex-wrap gl-display-flex gl-w-full">
- <div class="gl-mr-4 gl-display-flex gl-align-items-center">
- <p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
- </div>
- <div v-if="data.link">
- <gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
- </div>
- <div v-if="data.supportingText">
- <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p>
- </div>
- <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
- {{ data.badge.text }}
- </gl-badge>
-
- <actions
- :widget="$options.label || $options.name"
- :tertiary-buttons="data.actions"
- class="gl-ml-auto"
- />
- </div>
- <p
- v-if="data.subtext"
- v-safe-html="generateText(data.subtext)"
- class="gl-m-0 gl-font-sm"
- ></p>
+ <child-content :data="item" :widget-label="widgetLabel" :level="2" />
</gl-intersection-observer>
</div>
- </div>
- </li>
- </smart-virtual-list>
+ </dynamic-scroller-item>
+ </template>
+ </dynamic-scroller>
<div
:class="{ show: showFade }"
class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7 gl-pointer-events-none"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
new file mode 100644
index 00000000000..5f42c6c7acb
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlBadge, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
+import StatusIcon from './status_icon.vue';
+import Actions from './actions.vue';
+import { generateText } from './utils';
+
+export default {
+ name: 'ChildContent',
+ components: {
+ GlBadge,
+ GlLink,
+ StatusIcon,
+ Actions,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ data: {
+ type: Object,
+ required: true,
+ },
+ widgetLabel: {
+ type: String,
+ required: true,
+ },
+ level: {
+ type: Number,
+ required: true,
+ },
+ },
+ methods: {
+ isArray(arr) {
+ return Array.isArray(arr);
+ },
+ generateText,
+ },
+};
+</script>
+
+<template>
+ <div :class="{ 'gl-pl-6': level === 3 }" class="gl-w-full">
+ <div v-if="data.header" class="gl-mb-2">
+ <template v-if="isArray(data.header)">
+ <component
+ :is="headerI === 0 ? 'strong' : 'span'"
+ v-for="(header, headerI) in data.header"
+ :key="headerI"
+ v-safe-html="generateText(header)"
+ class="gl-display-block"
+ />
+ </template>
+ <strong v-else v-safe-html="generateText(data.header)"></strong>
+ </div>
+ <div class="gl-display-flex">
+ <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" />
+ <div class="gl-w-full">
+ <div class="gl-flex-wrap gl-display-flex gl-w-full">
+ <div class="gl-mr-4 gl-display-flex gl-align-items-center">
+ <p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
+ </div>
+ <div v-if="data.link">
+ <gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
+ </div>
+ <div v-if="data.supportingText">
+ <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p>
+ </div>
+ <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
+ {{ data.badge.text }}
+ </gl-badge>
+ <actions :widget="widgetLabel" :tertiary-buttons="data.actions" class="gl-ml-auto" />
+ </div>
+ <p
+ v-if="data.subtext"
+ v-safe-html="generateText(data.subtext)"
+ class="gl-m-0 gl-font-sm"
+ ></p>
+ </div>
+ </div>
+ <template v-if="data.children && level === 2">
+ <ul class="gl-m-0 gl-p-0 gl-list-style-none">
+ <li>
+ <child-content
+ v-for="childData in data.children"
+ :key="childData.id"
+ :data="childData"
+ :widget-label="widgetLabel"
+ :level="3"
+ data-testid="child-content"
+ />
+ </li>
+ </ul>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
index b75f2dce54e..f5667aee15b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -70,7 +70,9 @@ export default {
<template v-if="isCollapsed">
<slot name="header"></slot>
<gl-button
- variant="link"
+ category="tertiary"
+ variant="confirm"
+ size="small"
data-testid="mr-collapsible-title"
:disabled="isLoading"
:class="{ 'border-0': isLoading }"
@@ -81,7 +83,9 @@ export default {
</template>
<gl-button
v-else
- variant="link"
+ category="tertiary"
+ variant="confirm"
+ size="small"
data-testid="mr-collapsible-title"
:disabled="isLoading"
:class="{ 'border-0': isLoading }"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
index 68cff1368af..b062833cdf8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -1,6 +1,7 @@
<script>
/* eslint-disable @gitlab/require-i18n-strings */
import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { escapeShellString } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -10,24 +11,26 @@ export default {
steps: {
step1: {
label: __('Step 1.'),
- help: __('Fetch and check out the branch for this merge request'),
+ help: __("Fetch and check out this merge request's feature branch:"),
},
step2: {
label: __('Step 2.'),
- help: __('Review the changes locally'),
+ help: __('Review the changes locally.'),
},
step3: {
label: __('Step 3.'),
- help: __('Merge the branch and fix any conflicts that come up'),
+ help: __(
+ 'Merge the feature branch into the target branch and fix any conflicts. %{linkStart}How do I fix them?%{linkEnd}',
+ ),
},
step4: {
label: __('Step 4.'),
- help: __('Push the result of the merge to GitLab'),
+ help: __('Push the target branch up to GitLab.'),
},
},
copyCommands: __('Copy commands'),
tip: __(
- '%{strongStart}Tip:%{strongEnd} You can also checkout merge requests locally by %{linkStart}following these guidelines%{linkEnd}',
+ '%{strongStart}Tip:%{strongEnd} You can also check out merge requests locally. %{linkStart}Learn more.%{linkEnd}',
),
title: __('Check out, review, and merge locally'),
},
@@ -74,6 +77,13 @@ export default {
default: null,
},
},
+ data() {
+ return {
+ resolveConflictsFromCli: helpPagePath('ee/user/project/merge_requests/conflicts.html', {
+ anchor: 'resolve-conflicts-from-the-command-line',
+ }),
+ };
+ },
computed: {
mergeInfo1() {
const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`);
@@ -138,7 +148,13 @@ export default {
<strong>
{{ $options.i18n.steps.step3.label }}
</strong>
- {{ $options.i18n.steps.step3.help }}
+ <gl-sprintf :message="$options.i18n.steps.step3.help">
+ <template #link="{ content }">
+ <gl-link class="gl-display-inline-block" :href="resolveConflictsFromCli">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
</p>
<div class="gl-display-flex">
<pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo2 }}</pre>
@@ -163,7 +179,7 @@ export default {
/>
</div>
<p v-if="reviewingDocsPath">
- <gl-sprintf :message="$options.i18n.tip">
+ <gl-sprintf data-testid="docs-tip" :message="$options.i18n.tip">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
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 730d11b1208..2cef37d5c2e 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,5 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlLink } from '@gitlab/ui';
import { s__, n__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -8,6 +8,9 @@ export default {
directives: {
SafeHtml,
},
+ components: {
+ GlLink,
+ },
mixins: [glFeatureFlagMixin()],
props: {
relatedLinks: {
@@ -37,6 +40,17 @@ export default {
return n__('mrWidget|Closes issue', 'mrWidget|Closes issues', this.relatedLinks.closingCount);
},
+ assignIssueText() {
+ if (this.relatedLinks.unassignedCount > 1) {
+ return s__('mrWidget|Assign yourself to these issues');
+ }
+ return s__('mrWidget|Assign yourself to this issue');
+ },
+ shouldShowAssignToMeLink() {
+ return (
+ this.relatedLinks.unassignedCount && this.relatedLinks.assignToMe && this.showAssignToMe
+ );
+ },
},
};
</script>
@@ -44,23 +58,28 @@ export default {
<section>
<p
v-if="relatedLinks.closing"
- :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
+ :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
>
{{ closesText }}
<span v-safe-html="relatedLinks.closing"></span>
</p>
<p
v-if="relatedLinks.mentioned"
- :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
+ :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
>
+ <span v-if="relatedLinks.closing && glFeatures.restructuredMrWidget">&middot;</span>
{{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }}
<span v-safe-html="relatedLinks.mentioned"></span>
</p>
<p
- v-if="relatedLinks.assignToMe && showAssignToMe"
- :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
+ v-if="shouldShowAssignToMeLink"
+ :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
>
- <span v-html="relatedLinks.assignToMe /* eslint-disable-line vue/no-v-html */"></span>
+ <span>
+ <gl-link rel="nofollow" data-method="post" :href="relatedLinks.assignToMe">{{
+ assignIssueText
+ }}</gl-link>
+ </span>
</p>
</section>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
index 73d75352cb5..5baeb309f79 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
@@ -21,7 +21,9 @@ export default {
<gl-dropdown
right
text="Use an existing commit message"
- variant="link"
+ category="tertiary"
+ variant="confirm"
+ size="small"
class="mr-commit-dropdown"
>
<gl-dropdown-item
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 5c4a526bcc3..400759aa086 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
@@ -77,7 +77,7 @@ export default {
:target-branch="targetBranch"
/>
</span>
- <gl-button variant="link" class="modify-message-button">
+ <gl-button category="tertiary" variant="confirm" size="small" class="modify-message-button">
{{ modifyLinkMessage }}
</gl-button>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index a2c9cfe53cc..7435f578852 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -82,17 +82,8 @@ export default {
return this.mr.shouldBeRebased;
},
- sourceBranchProtected() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.stateData.sourceBranchProtected;
- }
-
- return this.mr.sourceBranchProtected;
- },
showResolveButton() {
- return (
- this.mr.conflictResolutionPath && this.canPushToSourceBranch && !this.sourceBranchProtected
- );
+ return this.mr.conflictResolutionPath && this.canPushToSourceBranch;
},
},
};
@@ -144,7 +135,7 @@ export default {
:size="glFeatures.restructuredMrWidget ? 'small' : 'medium'"
data-testid="merge-locally-button"
>
- {{ s__('mrWidget|Merge locally') }}
+ {{ s__('mrWidget|Resolve locally') }}
</gl-button>
</template>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index bb0fb410d3e..ebdc8309cd5 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
@@ -3,13 +3,11 @@ import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import ActionsButton from '~/vue_shared/components/actions_button.vue';
import simplePoll from '../../../lib/utils/simple_poll';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import rebaseQuery from '../../queries/states/rebase.query.graphql';
import statusIcon from '../mr_widget_status_icon.vue';
-import { REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY } from '../../constants';
export default {
name: 'MRWidgetRebase',
@@ -28,7 +26,6 @@ export default {
components: {
statusIcon,
GlSkeletonLoader,
- ActionsButton,
GlButton,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
@@ -47,7 +44,6 @@ export default {
state: {},
isMakingRequest: false,
rebasingError: null,
- selectedRebaseAction: REBASE_BUTTON_KEY,
};
},
computed: {
@@ -93,28 +89,6 @@ export default {
fastForwardMergeText() {
return __('Merge blocked: the source branch must be rebased onto the target branch.');
},
- actions() {
- return [this.rebaseAction, this.rebaseWithoutCiAction].filter((action) => action);
- },
- rebaseAction() {
- return {
- key: REBASE_BUTTON_KEY,
- text: __('Rebase'),
- secondaryText: __('Rebases and triggers a pipeline'),
- attrs: {
- 'data-qa-selector': 'mr_rebase_button',
- },
- handle: () => this.rebase(),
- };
- },
- rebaseWithoutCiAction() {
- return {
- key: REBASE_WITHOUT_CI_BUTTON_KEY,
- text: __('Rebase without CI'),
- secondaryText: __('Performs a rebase but skips triggering a new pipeline'),
- handle: () => this.rebase({ skipCi: true }),
- };
- },
},
methods: {
rebase({ skipCi = false } = {}) {
@@ -138,8 +112,8 @@ export default {
}
});
},
- selectRebaseAction(key) {
- this.selectedRebaseAction = key;
+ rebaseWithoutCi() {
+ return this.rebase({ skipCi: true });
},
checkRebaseStatus(continuePolling, stopPolling) {
this.service
@@ -198,10 +172,10 @@ export default {
>
<div
v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
- class="accept-merge-holder clearfix js-toggle-container accept-action media space-children"
+ class="accept-merge-holder clearfix js-toggle-container accept-action media space-children gl-align-items-center"
>
<gl-button
- v-if="!glFeatures.restructuredMrWidget && !showRebaseWithoutCi"
+ v-if="!glFeatures.restructuredMrWidget"
:loading="isMakingRequest"
variant="confirm"
data-qa-selector="mr_rebase_button"
@@ -210,14 +184,16 @@ export default {
>
{{ __('Rebase') }}
</gl-button>
- <actions-button
+ <gl-button
v-if="!glFeatures.restructuredMrWidget && showRebaseWithoutCi"
- :actions="actions"
- :selected-key="selectedRebaseAction"
+ :loading="isMakingRequest"
variant="confirm"
- category="primary"
- @select="selectRebaseAction"
- />
+ category="secondary"
+ data-testid="rebase-without-ci-button"
+ @click="rebaseWithoutCi"
+ >
+ {{ __('Rebase without pipeline') }}
+ </gl-button>
<span
v-if="!rebasingError"
:class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
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 bc094501e89..4f8faeb877f 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
@@ -82,6 +82,13 @@ export default {
};
this.loading = false;
+ if (!this.commitMessageIsTouched) {
+ this.commitMessage = this.state.defaultMergeCommitMessage;
+ }
+ if (!this.squashCommitMessageIsTouched) {
+ this.squashCommitMessage = this.state.defaultSquashCommitMessage;
+ }
+
if (this.state.mergeTrainsCount !== null && this.state.mergeTrainsCount !== undefined) {
this.initPolling();
}
@@ -133,9 +140,11 @@ export default {
isMakingRequest: false,
isMergingImmediately: false,
commitMessage: this.mr.commitMessage,
+ commitMessageIsTouched: false,
squashBeforeMerge: this.mr.squashIsSelected,
isSquashReadOnly: this.mr.squashIsReadonly,
squashCommitMessage: this.mr.squashCommitMessage,
+ squashCommitMessageIsTouched: false,
isPipelineFailedModalVisibleMergeTrain: false,
isPipelineFailedModalVisibleNormalMerge: false,
editCommitMessage: false,
@@ -295,13 +304,6 @@ export default {
return enableSquashBeforeMerge;
},
- shouldShowMergeControls() {
- if (this.glFeatures.restructuredMrWidget) {
- return this.restructuredWidgetShowMergeButtons;
- }
-
- return this.isMergeAllowed || this.isAutoMergeAvailable;
- },
shouldShowSquashEdit() {
return this.squashBeforeMerge && this.shouldShowSquashBeforeMerge;
},
@@ -472,6 +474,14 @@ export default {
});
});
},
+ setCommitMessage(val) {
+ this.commitMessage = val;
+ this.commitMessageIsTouched = true;
+ },
+ setSquashCommitMessage(val) {
+ this.squashCommitMessage = val;
+ this.squashCommitMessageIsTouched = true;
+ },
},
i18n: {
mergeCommitTemplateHintText: s__(
@@ -637,21 +647,23 @@ export default {
>
<commit-edit
v-if="shouldShowSquashEdit"
- v-model="squashCommitMessage"
+ :value="squashCommitMessage"
:label="__('Squash commit message')"
input-id="squash-message-edit"
class="gl-m-0! gl-p-0!"
+ @input="setSquashCommitMessage"
>
<template #header>
- <commit-message-dropdown v-model="squashCommitMessage" :commits="commits" />
+ <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" />
</template>
</commit-edit>
<commit-edit
v-if="shouldShowMergeEdit"
- v-model="commitMessage"
+ :value="commitMessage"
:label="__('Merge commit message')"
input-id="merge-message-edit"
class="gl-m-0! gl-p-0!"
+ @input="setCommitMessage"
/>
<li class="gl-m-0! gl-p-0!">
<p class="form-text text-muted">
@@ -755,20 +767,22 @@ export default {
<ul class="border-top content-list commits-list flex-list">
<commit-edit
v-if="shouldShowSquashEdit"
- v-model="squashCommitMessage"
+ :value="squashCommitMessage"
:label="__('Squash commit message')"
input-id="squash-message-edit"
squash
+ @input="setSquashCommitMessage"
>
<template #header>
- <commit-message-dropdown v-model="squashCommitMessage" :commits="commits" />
+ <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" />
</template>
</commit-edit>
<commit-edit
v-if="shouldShowMergeEdit"
- v-model="commitMessage"
+ :value="commitMessage"
:label="__('Merge commit message')"
input-id="merge-message-edit"
+ @input="setCommitMessage"
/>
<li>
<p class="form-text text-muted">
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index d337a554663..533bb38a88c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -166,6 +166,3 @@ export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500';
export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700';
export { STATE_MACHINE };
-
-export const REBASE_BUTTON_KEY = 'rebase';
-export const REBASE_WITHOUT_CI_BUTTON_KEY = 'rebaseWithoutCi';
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
new file mode 100644
index 00000000000..d32db50874c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
@@ -0,0 +1,123 @@
+import { n__, s__, sprintf } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
+import { SEVERITY_ICONS_EXTENSION } from '~/reports/codequality_report/constants';
+import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+
+export default {
+ name: 'WidgetCodeQuality',
+ props: ['codeQuality', 'blobPath'],
+ i18n: {
+ label: s__('ciReport|Code Quality'),
+ loading: s__('ciReport|Code Quality test metrics results are being parsed'),
+ error: s__('ciReport|Code Quality failed loading results'),
+ },
+ expandEvent: 'i_testing_code_quality_widget_total',
+ computed: {
+ summary() {
+ const { newErrors, resolvedErrors, errorSummary } = this.collapsedData;
+ if (errorSummary.errored >= 1 && errorSummary.resolved >= 1) {
+ const improvements = sprintf(
+ n__(
+ '%{strongOpen}%{errors}%{strongClose} point',
+ '%{strongOpen}%{errors}%{strongClose} points',
+ resolvedErrors.length,
+ ),
+ {
+ errors: resolvedErrors.length,
+ strongOpen: '<strong>',
+ strongClose: '</strong>',
+ },
+ false,
+ );
+
+ const degradations = sprintf(
+ n__(
+ '%{strongOpen}%{errors}%{strongClose} point',
+ '%{strongOpen}%{errors}%{strongClose} points',
+ newErrors.length,
+ ),
+ { errors: newErrors.length, strongOpen: '<strong>', strongClose: '</strong>' },
+ false,
+ );
+ return sprintf(
+ s__(`ciReport|Code Quality improved on ${improvements} and degraded on ${degradations}.`),
+ );
+ } else if (errorSummary.resolved >= 1) {
+ const improvements = n__('%d point', '%d points', resolvedErrors.length);
+ return sprintf(s__(`ciReport|Code Quality improved on ${improvements}.`));
+ } else if (errorSummary.errored >= 1) {
+ const degradations = n__('%d point', '%d points', newErrors.length);
+ return sprintf(s__(`ciReport|Code Quality degraded on ${degradations}.`));
+ }
+ return s__(`ciReport|No changes to Code Quality.`);
+ },
+ statusIcon() {
+ if (this.collapsedData.errorSummary?.errored >= 1) {
+ return EXTENSION_ICONS.warning;
+ }
+ return EXTENSION_ICONS.success;
+ },
+ },
+ methods: {
+ fetchCollapsedData() {
+ return Promise.all([this.fetchReport(this.codeQuality)]).then((values) => {
+ return {
+ resolvedErrors: parseCodeclimateMetrics(
+ values[0].resolved_errors,
+ this.blobPath.head_path,
+ ),
+ newErrors: parseCodeclimateMetrics(values[0].new_errors, this.blobPath.head_path),
+ existingErrors: parseCodeclimateMetrics(
+ values[0].existing_errors,
+ this.blobPath.head_path,
+ ),
+ errorSummary: values[0].summary,
+ };
+ });
+ },
+ fetchFullData() {
+ const fullData = [];
+
+ this.collapsedData.newErrors.map((e) => {
+ return fullData.push({
+ text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
+ subtext: sprintf(
+ s__(`ciReport|in %{open_link}${e.file_path}:${e.line}%{close_link}`),
+ {
+ open_link: `<a class="gl-text-decoration-underline" href="${e.urlPath}">`,
+ close_link: '</a>',
+ },
+ false,
+ ),
+ icon: {
+ name: SEVERITY_ICONS_EXTENSION[e.severity],
+ },
+ });
+ });
+
+ this.collapsedData.resolvedErrors.map((e) => {
+ return fullData.push({
+ text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
+ subtext: sprintf(
+ s__(`ciReport|in %{open_link}${e.file_path}:${e.line}%{close_link}`),
+ {
+ open_link: `<a class="gl-text-decoration-underline" href="${e.urlPath}">`,
+ close_link: '</a>',
+ },
+ false,
+ ),
+ icon: {
+ name: SEVERITY_ICONS_EXTENSION[e.severity],
+ },
+ });
+ });
+
+ return Promise.resolve(fullData);
+ },
+ fetchReport(endpoint) {
+ return axios.get(endpoint).then((res) => res.data);
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
index 4aeebf095c4..e52f2c2c666 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -88,6 +88,16 @@ export default {
// text: 'Link text', // Required: Text to be used inside the link
// },
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
+ children: [
+ {
+ id: `child-${issue.id}`,
+ header: 'New',
+ text: '%{critical_start}1 Critical%{critical_end}',
+ icon: {
+ name: EXTENSION_ICONS.error,
+ },
+ },
+ ],
}));
});
},
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 247a3711fc8..627ddb0445e 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
@@ -1,8 +1,6 @@
import { __ } from '~/locale';
-export const MERGE_DISABLED_TEXT = __(
- 'Merge blocked: all merge request dependencies must be merged or closed.',
-);
+export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
export const MERGE_DISABLED_SKIPPED_PIPELINE_TEXT = __(
"Merge blocked: pipeline must succeed. It's waiting for a manual job to continue.",
);
@@ -22,6 +20,13 @@ export default {
this.mr.preventMerge,
);
},
+ shouldShowMergeControls() {
+ if (this.glFeatures.restructuredMrWidget) {
+ return this.restructuredWidgetShowMergeButtons;
+ }
+
+ return this.isMergeAllowed || this.isAutoMergeAvailable;
+ },
mergeDisabledText() {
if (this.pipeline?.status === PIPELINE_SKIPPED_STATUS) {
return MERGE_DISABLED_SKIPPED_PIPELINE_TEXT;
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 11de58aa344..965746e79fb 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
@@ -46,6 +46,7 @@ import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variab
import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform';
import accessibilityExtension from './extensions/accessibility';
+import codeQualityExtension from './extensions/code_quality';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
@@ -241,6 +242,11 @@ export default {
this.registerTerraformPlans();
}
},
+ shouldRenderCodeQuality(newVal) {
+ if (newVal) {
+ this.registerCodeQualityExtension();
+ }
+ },
shouldShowAccessibilityReport(newVal) {
if (newVal) {
this.registerAccessibilityExtension();
@@ -352,6 +358,8 @@ export default {
return Promise.resolve();
},
initPolling() {
+ if (this.startingPollInterval <= 0) return;
+
this.pollingInterval = new SmartInterval({
callback: this.checkStatus,
startingInterval: this.startingPollInterval,
@@ -435,10 +443,10 @@ export default {
notify.notifyMe(title, message, this.mr.gitlabLogo);
},
resumePolling() {
- this.pollingInterval.resume();
+ this.pollingInterval?.resume();
},
stopPolling() {
- this.pollingInterval.stopTimer();
+ this.pollingInterval?.stopTimer();
},
bindEventHubListeners() {
eventHub.$on('MRWidgetUpdateRequested', (cb) => {
@@ -489,6 +497,11 @@ export default {
registerExtension(accessibilityExtension);
}
},
+ registerCodeQualityExtension() {
+ if (this.shouldRenderCodeQuality && this.shouldShowExtension) {
+ registerExtension(codeQualityExtension);
+ }
+ },
},
};
</script>
@@ -544,7 +557,7 @@ export default {
</div>
<extensions-container :mr="mr" />
<grouped-codequality-reports-app
- v-if="shouldRenderCodeQuality"
+ v-if="shouldRenderCodeQuality && !shouldShowExtension"
:head-blob-path="mr.headBlobPath"
:base-blob-path="mr.baseBlobPath"
:codequality-reports-path="mr.codequalityReportsPath"
@@ -574,7 +587,7 @@ export default {
/>
<grouped-accessibility-reports-app
- v-if="shouldShowAccessibilityReport"
+ v-if="shouldShowAccessibilityReport && !shouldShowExtension"
:endpoint="mr.accessibilityReportPath"
/>
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
index d85794f7245..99e6f4e9beb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
@@ -1,9 +1,11 @@
fragment ReadyToMerge on Project {
+ __typename
id
onlyAllowMergeIfPipelineSucceeds
mergeRequestsFfOnlyEnabled
squashReadOnly
mergeRequest(iid: $iid) {
+ __typename
id
autoMergeEnabled
shouldRemoveSourceBranch
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 5378dabf638..eb07609d5d6 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
@@ -32,9 +32,15 @@ export default class MergeRequestStore {
this.setPaths(data);
this.setData(data);
+ this.initCodeQualityReport(data);
this.setGitpodData(data);
}
+ initCodeQualityReport(data) {
+ this.blobPath = data.blob_path;
+ this.codeQuality = data.codequality_reports_path;
+ }
+
setData(data, isRebased) {
this.initApprovals();
@@ -82,14 +88,16 @@ export default class MergeRequestStore {
const { closing } = links;
const mentioned = links.mentioned_but_not_closing;
const assignToMe = links.assign_to_closing;
+ const unassignedCount = links.assign_to_closing_count;
- if (closing || mentioned || assignToMe) {
+ if (closing || mentioned || unassignedCount) {
this.relatedLinks = {
closing,
mentioned,
assignToMe,
closingCount: links.closing_count,
mentionedCount: links.mentioned_count,
+ unassignedCount: links.assign_to_closing_count,
};
}
}
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index b6010d4b70c..96970f4ce2f 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -199,12 +199,15 @@ export default {
<div v-if="canAwardEmoji" class="award-menu-holder gl-my-2">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
+ v-gl-tooltip.viewport
+ :title="__('Add reaction')"
:toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]"
@click="handleAward"
@shown="setIsMenuOpen(true)"
@hidden="setIsMenuOpen(false)"
>
<template #button-content>
+ <span class="gl-sr-only">{{ __('Add reaction') }}</span>
<span class="reaction-control-icon reaction-control-icon-neutral">
<gl-icon name="slight-smile" />
</span>
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 7563c35dfc8..7a166f9a3e4 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
@@ -7,6 +7,7 @@
:invalid-feedback="__('Please enter a valid hex (#RRGGBB or #RGB) color value')"
:label="__('Background color')"
:value="#FF0000"
+ :suggestedColors="{ '#ff0000': 'Red', '#808080': 'Gray' }",
state="isValidColor"
/>
*/
@@ -48,6 +49,11 @@ export default {
required: false,
default: null,
},
+ suggestedColors: {
+ type: Object,
+ required: false,
+ default: () => gon.suggested_label_colors,
+ },
},
computed: {
description() {
@@ -55,9 +61,6 @@ export default {
? this.$options.i18n.fullDescription
: this.$options.i18n.shortDescription;
},
- suggestedColors() {
- return gon.suggested_label_colors;
- },
previewColor() {
if (this.state) {
return { backgroundColor: this.value };
diff --git a/app/assets/javascripts/vue_shared/components/content_transition.vue b/app/assets/javascripts/vue_shared/components/content_transition.vue
new file mode 100644
index 00000000000..446610d6b91
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_transition.vue
@@ -0,0 +1,32 @@
+<script>
+export default {
+ props: {
+ currentSlot: {
+ type: String,
+ required: true,
+ },
+ slots: {
+ type: Array,
+ required: true,
+ },
+ transitionName: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ shouldShow(key) {
+ return this.currentSlot === key;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <transition v-for="{ key, attributes } in slots" :key="key" :name="transitionName">
+ <div v-show="shouldShow(key)" v-bind="attributes">
+ <slot :name="key"></slot>
+ </div>
+ </transition>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
index 153b0981813..2a79ccc2648 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
@@ -1,22 +1,28 @@
<script>
import {
+ GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlSearchBoxByType,
} from '@gitlab/ui';
import { __ } from '~/locale';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
+ GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlSearchBoxByType,
+ TooltipOnTruncate,
},
props: {
selectText: {
@@ -39,6 +45,11 @@ export default {
required: false,
default: () => [],
},
+ groupedOptions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
isLoading: {
type: Boolean,
required: false,
@@ -79,11 +90,7 @@ export default {
if (Array.isArray(this.selected)) {
return this.selected.some((label) => label.title === option.title);
}
- return (
- this.selected &&
- ((option.name && this.selected.name === option.name) ||
- (option.title && this.selected.title === option.title))
- );
+ return this.selected && option.id && this.selected.id === option.id;
},
showDropdown() {
this.$refs.dropdown.show();
@@ -101,6 +108,9 @@ export default {
// TODO: this has some knowledge of the context where the component is used. We could later rework it.
return option.username || null;
},
+ optionKey(option) {
+ return option.key ? option.key : option.id;
+ },
},
i18n: {
noMatchingResults: __('No matching results'),
@@ -154,10 +164,10 @@ export default {
</template>
<gl-dropdown-item
v-for="option in options"
- :key="option.id"
+ :key="optionKey(option)"
:is-checked="isSelected(option)"
- :is-check-centered="true"
- :is-check-item="true"
+ is-check-centered
+ is-check-item
:avatar-url="avatarUrl(option)"
:secondary-text="secondaryText(option)"
data-testid="unselected-option"
@@ -167,6 +177,36 @@ export default {
{{ option.title }}
</slot>
</gl-dropdown-item>
+ <template v-for="(optionGroup, index) in groupedOptions">
+ <gl-dropdown-divider v-if="index !== 0" :key="index" />
+ <gl-dropdown-section-header :key="optionGroup.id">
+ <div class="gl-display-flex gl-max-w-full">
+ <tooltip-on-truncate
+ :title="optionGroup.title"
+ class="gl-text-truncate gl-flex-grow-1"
+ >
+ {{ optionGroup.title }}
+ </tooltip-on-truncate>
+ <span v-if="optionGroup.secondaryText" class="gl-float-right gl-font-weight-normal">
+ <gl-icon name="clock" class="gl-mr-2" />
+ {{ optionGroup.secondaryText }}
+ </span>
+ </div>
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="option in optionGroup.options"
+ :key="optionKey(option)"
+ :is-checked="isSelected(option)"
+ is-check-centered
+ is-check-item
+ data-testid="unselected-option"
+ @click="selectOption(option)"
+ >
+ <slot name="item" :item="option">
+ {{ option.title }}
+ </slot>
+ </gl-dropdown-item>
+ </template>
<gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!">
{{ $options.i18n.noMatchingResults }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index 157068b2c0f..e7923e0b55e 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -76,9 +76,10 @@ export default {
},
data() {
return {
+ hasFetched: false, // use this to avoid flash of `No suggestions found` before fetching
searchKey: '',
recentSuggestions: this.config.recentSuggestionsStorageKey
- ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey)
+ ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey) ?? []
: [],
};
},
@@ -86,6 +87,9 @@ export default {
isRecentSuggestionsEnabled() {
return Boolean(this.config.recentSuggestionsStorageKey);
},
+ suggestionsEnabled() {
+ return !this.config.suggestionsDisabled;
+ },
recentTokenIds() {
return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]);
},
@@ -134,17 +138,6 @@ export default {
showAvailableSuggestions() {
return this.availableSuggestions.length > 0;
},
- showSuggestions() {
- // These conditions must match the template under `#suggestions` slot
- // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65817#note_632619411
- return (
- this.showDefaultSuggestions ||
- this.showRecentSuggestions ||
- this.showPreloadedSuggestions ||
- this.suggestionsLoading ||
- this.showAvailableSuggestions
- );
- },
searchTerm() {
return this.searchBy && this.activeTokenValue
? this.activeTokenValue[this.searchBy]
@@ -161,6 +154,13 @@ export default {
}
},
},
+ suggestionsLoading: {
+ handler(loading) {
+ if (loading) {
+ this.hasFetched = true;
+ }
+ },
+ },
},
methods: {
handleInput: debounce(function debouncedSearch({ data, operator }) {
@@ -216,7 +216,7 @@ export default {
<template #view="viewTokenProps">
<slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
</template>
- <template v-if="showSuggestions" #suggestions>
+ <template v-if="suggestionsEnabled" #suggestions>
<template v-if="showDefaultSuggestions">
<gl-filtered-search-suggestion
v-for="token in availableDefaultSuggestions"
@@ -238,12 +238,13 @@ export default {
:suggestions="preloadedSuggestions"
></slot>
<gl-loading-icon v-if="suggestionsLoading" size="sm" />
+ <template v-else-if="showAvailableSuggestions">
+ <slot name="suggestions-list" :suggestions="availableSuggestions"></slot>
+ </template>
<gl-dropdown-text v-else-if="showNoMatchesText">
{{ __('No matches found') }}
</gl-dropdown-text>
- <template v-else>
- <slot name="suggestions-list" :suggestions="availableSuggestions"></slot>
- </template>
+ <gl-dropdown-text v-else-if="hasFetched">{{ __('No suggestions found') }}</gl-dropdown-text>
</template>
</gl-filtered-search-token>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index cbf38984e23..e1020ce656b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -48,6 +48,11 @@ export default {
required: false,
default: '',
},
+ enablePreview: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
addSpacingClasses: {
type: Boolean,
required: false,
@@ -113,6 +118,7 @@ export default {
markdownPreviewLoading: false,
previewMarkdown: false,
suggestions: this.note.suggestions || [],
+ debouncedFetchMarkdownLoading: false,
};
},
computed: {
@@ -198,12 +204,22 @@ export default {
const justRemovedAll = hadAll && !hasAll;
if (justAddedAll) {
+ this.debouncedFetchMarkdownLoading = false;
this.debouncedFetchMarkdown();
} else if (justRemovedAll) {
+ this.debouncedFetchMarkdownLoading = true;
this.referencedUsers = [];
}
},
},
+ enablePreview: {
+ immediate: true,
+ handler(newVal) {
+ if (!newVal) {
+ this.showWriteTab();
+ }
+ },
+ },
},
mounted() {
// GLForm class handles all the toolbar buttons
@@ -271,7 +287,12 @@ export default {
},
debouncedFetchMarkdown: debounce(function debouncedFetchMarkdown() {
- return this.fetchMarkdown();
+ return this.fetchMarkdown().then(() => {
+ if (this.debouncedFetchMarkdownLoading) {
+ this.referencedUsers = [];
+ this.debouncedFetchMarkdownLoading = false;
+ }
+ });
}, 400),
renderMarkdown(data = {}) {
@@ -301,6 +322,7 @@ export default {
:preview-markdown="previewMarkdown"
:line-content="lineContent"
:can-suggest="canSuggest"
+ :enable-preview="enablePreview"
:show-suggest-popover="showSuggestPopover"
:suggestion-start-index="suggestionsStartIndex"
data-testid="markdownHeader"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 3b99afa9e3d..13189670e17 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,7 +1,13 @@
<script>
import { GlPopover, GlButton, GlTooltipDirective, GlTabs, GlTab } from '@gitlab/ui';
import $ from 'jquery';
-import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings';
+import {
+ keysFor,
+ BOLD_TEXT,
+ ITALIC_TEXT,
+ STRIKETHROUGH_TEXT,
+ LINK_TEXT,
+} from '~/behaviors/shortcuts/keybindings';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
@@ -43,6 +49,11 @@ export default {
required: false,
default: 0,
},
+ enablePreview: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -144,6 +155,7 @@ export default {
shortcuts: {
bold: keysFor(BOLD_TEXT),
italic: keysFor(ITALIC_TEXT),
+ strikethrough: keysFor(STRIKETHROUGH_TEXT),
link: keysFor(LINK_TEXT),
},
i18n: {
@@ -164,6 +176,7 @@ export default {
@click="writeMarkdownTab($event)"
/>
<gl-tab
+ v-if="enablePreview"
title-link-class="gl-pt-3 gl-px-3 js-md-preview-button"
:title="$options.i18n.previewTabTitle"
:active="previewMarkdown"
@@ -194,6 +207,16 @@ export default {
icon="italic"
/>
<toolbar-button
+ tag="~~"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), {
+ modifierKey,
+ })
+ "
+ :shortcuts="$options.shortcuts.strikethrough"
+ icon="strikethrough"
+ />
+ <toolbar-button
:prepend="true"
:tag="tag"
:button-title="__('Insert a quote')"
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 0b302f22062..7a7074da084 100644
--- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -1,12 +1,7 @@
<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
-import { escape } from 'lodash';
+import { GlLink, GlIcon, GlSprintf } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-function buildDocsLinkStart(path) {
- return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`;
-}
-
const NoteableTypeText = {
Issue: __('issue'),
Epic: __('epic'),
@@ -17,6 +12,7 @@ export default {
components: {
GlIcon,
GlLink,
+ GlSprintf,
},
props: {
isLocked: {
@@ -59,20 +55,6 @@ export default {
noteableTypeText() {
return NoteableTypeText[this.noteableType];
},
- confidentialAndLockedDiscussionText() {
- return sprintf(
- __(
- 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.',
- ),
- {
- noteableTypeText: this.noteableTypeText,
- confidentialLinkStart: buildDocsLinkStart(this.confidentialNoteableDocsPath),
- lockedLinkStart: buildDocsLinkStart(this.lockedNoteableDocsPath),
- linkEnd: '</a>',
- },
- false,
- );
- },
confidentialContextText() {
return sprintf(__('This is a confidential %{noteableTypeText}.'), {
noteableTypeText: this.noteableTypeText,
@@ -91,9 +73,23 @@ export default {
<gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" />
<span v-if="isLockedAndConfidential" ref="lockedAndConfidential">
- <span
- v-html="confidentialAndLockedDiscussionText /* eslint-disable-line vue/no-v-html */"
- ></span>
+ <span>
+ <gl-sprintf
+ :message="
+ __(
+ 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{confidentialLinkEnd} and %{lockedLinkStart}locked%{lockedLinkEnd}.',
+ )
+ "
+ >
+ <template #noteableTypeText>{{ noteableTypeText }}</template>
+ <template #confidentialLink="{ content }">
+ <gl-link :href="confidentialNoteableDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #lockedLink="{ content }">
+ <gl-link :href="lockedNoteableDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </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/runner_aws_deployments/constants.js b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
index 46361c6eb32..88c975b97b9 100644
--- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
+++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
@@ -1,7 +1,5 @@
import { s__, sprintf } from '~/locale';
-export const EXPERIMENT_NAME = 'ci_runner_templates';
-
export const README_URL =
'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md';
@@ -16,7 +14,11 @@ export const EASY_BUTTONS = [
templateName:
'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml',
description: s__(
- 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. Default choice for Linux Docker executor.',
+ 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot.',
+ ),
+ moreDetails1: s__('Runners|No spot. This is the default choice for Linux Docker executor.'),
+ moreDetails2: s__(
+ 'Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
),
},
{
@@ -28,12 +30,20 @@ export const EASY_BUTTONS = [
),
{ percentage: '100%' },
),
+ moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
+ ),
},
{
stackName: 'win2019-shell-non-spot',
templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml',
description: s__(
- 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. Default choice for Windows Shell executor.',
+ 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot.',
+ ),
+ moreDetails1: s__('Runners|No spot. Default choice for Windows Shell executor.'),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
),
},
{
@@ -45,5 +55,9 @@ export const EASY_BUTTONS = [
),
{ percentage: '100%' },
),
+ moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
+ ),
},
];
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 57cc25caa25..eee65d90285 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,35 +1,44 @@
<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';
import {
- EXPERIMENT_NAME,
- README_URL,
- CF_BASE_URL,
- TEMPLATES_BASE_URL,
- EASY_BUTTONS,
-} from './constants';
+ GlModal,
+ GlSprintf,
+ GlLink,
+ GlFormRadioGroup,
+ GlFormRadio,
+ GlAccordion,
+ GlAccordionItem,
+} from '@gitlab/ui';
+import Tracking from '~/tracking';
+import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility';
+import { __, s__ } from '~/locale';
+import { README_URL, CF_BASE_URL, TEMPLATES_BASE_URL, EASY_BUTTONS } from './constants';
export default {
components: {
GlModal,
GlSprintf,
GlLink,
+ GlFormRadioGroup,
+ GlFormRadio,
+ GlAccordion,
+ GlAccordionItem,
},
+ mixins: [Tracking.mixin()],
props: {
modalId: {
type: String,
required: true,
},
- imgSrc: {
- type: String,
- required: false,
- default: awsCloudFormationImageUrl,
- },
+ },
+ data() {
+ return {
+ selected: this.$options.easyButtons[0],
+ };
},
methods: {
+ borderBottom(idx) {
+ return idx < this.$options.easyButtons.length - 1;
+ },
easyButtonUrl(easyButton) {
const params = {
templateURL: TEMPLATES_BASE_URL + easyButton.templateName,
@@ -39,21 +48,30 @@ export default {
return CF_BASE_URL + objectToQuery(params);
},
trackCiRunnerTemplatesClick(stackName) {
- const tracking = new ExperimentTracking(EXPERIMENT_NAME);
- tracking.event(`template_clicked_${stackName}`);
+ this.track('template_clicked', {
+ label: stackName,
+ });
+ },
+ handleModalPrimary() {
+ this.trackCiRunnerTemplatesClick(this.selected.stackName);
+ visitUrl(this.easyButtonUrl(this.selected), true);
},
},
i18n: {
title: s__('Runners|Deploy GitLab Runner in AWS'),
instructions: s__(
- 'Runners|For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet.',
+ 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.',
),
- dont_see_what_you_are_looking_for: s__(
- "Rnners|Don't see what you are looking for? See the full list of options, including a fully customizable option, %{linkStart}here%{linkEnd}.",
- ),
- note: s__(
- 'Runners|If you do not select an AWS VPC, the runner will deploy to the Default VPC in the AWS Region you select. Please consult with your AWS administrator to understand if there are any security risks to deploying into the Default VPC in any given region in your AWS account.',
+ chooseRunner: s__('Runners|Choose your preferred GitLab Runner'),
+ dontSeeWhatYouAreLookingFor: s__(
+ "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.",
),
+ moreDetails: __('More Details'),
+ lessDetails: __('Less Details'),
+ },
+ deployButton: {
+ text: s__('Runners|Deploy GitLab Runner in AWS'),
+ attributes: [{ variant: 'confirm' }],
},
closeButton: {
text: __('Cancel'),
@@ -67,37 +85,41 @@ export default {
<gl-modal
:modal-id="modalId"
:title="$options.i18n.title"
+ :action-primary="$options.deployButton"
:action-secondary="$options.closeButton"
size="sm"
+ @primary="handleModalPrimary"
>
<p>{{ $options.i18n.instructions }}</p>
- <ul class="gl-list-style-none gl-p-0 gl-mb-0">
- <li v-for="easyButton in $options.easyButtons" :key="easyButton.templateName">
- <gl-link
- :href="easyButtonUrl(easyButton)"
- target="_blank"
- class="gl-display-flex gl-font-weight-bold"
- @click="trackCiRunnerTemplatesClick(easyButton.stackName)"
- >
- <img
- :title="easyButton.stackName"
- :alt="easyButton.stackName"
- :src="imgSrc"
- width="46"
- height="46"
- class="gl-mt-2 gl-mr-5 gl-mb-6"
- />
+ <gl-form-radio-group v-model="selected" :label="$options.i18n.chooseRunner" label-sr-only>
+ <gl-form-radio
+ v-for="(easyButton, idx) in $options.easyButtons"
+ :key="easyButton.templateName"
+ :value="easyButton"
+ class="gl-py-5 gl-pl-8"
+ :class="{ 'gl-border-b': borderBottom(idx) }"
+ >
+ <div class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold">
{{ easyButton.description }}
- </gl-link>
- </li>
- </ul>
+ <gl-accordion :header-level="3" class="gl-pt-3">
+ <gl-accordion-item
+ :title="$options.i18n.moreDetails"
+ :title-visible="$options.i18n.lessDetails"
+ class="gl-font-weight-normal"
+ >
+ <p class="gl-pt-2">{{ easyButton.moreDetails1 }}</p>
+ <p class="gl-m-0">{{ easyButton.moreDetails2 }}</p>
+ </gl-accordion-item>
+ </gl-accordion>
+ </div>
+ </gl-form-radio>
+ </gl-form-radio-group>
<p>
- <gl-sprintf :message="$options.i18n.dont_see_what_you_are_looking_for">
+ <gl-sprintf :message="$options.i18n.dontSeeWhatYouAreLookingFor">
<template #link="{ content }">
<gl-link :href="$options.readmeUrl" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
- <p class="gl-font-sm gl-mb-0">{{ $options.i18n.note }}</p>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
index 81e19e48d75..7127940bb05 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
@@ -10,8 +10,14 @@ query getMrAssignees($fullPath: ID!, $iid: String!) {
nodes {
...User
...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
}
}
+ userPermissions {
+ canMerge
+ }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
index 77140ea36d8..5fec2ccbdfb 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
@@ -2,21 +2,18 @@
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
- mergeRequestSetAssignees(
+ issuableSetAssignees: mergeRequestSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
- mergeRequest {
+ issuable: mergeRequest {
id
assignees {
nodes {
...User
...UserAvailability
- }
- }
- participants {
- nodes {
- ...User
- ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue
index 011cad4267c..6a0bf07c8b4 100644
--- a/app/assets/javascripts/vue_shared/components/source_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/source_editor.vue
@@ -46,6 +46,11 @@ export default {
required: false,
default: () => ({}),
},
+ debounceValue: {
+ type: Number,
+ required: false,
+ default: CONTENT_UPDATE_DEBOUNCE,
+ },
},
data() {
return {
@@ -73,9 +78,7 @@ export default {
...this.editorOptions,
});
- this.editor.onDidChangeModelContent(
- debounce(this.onFileChange.bind(this), CONTENT_UPDATE_DEBOUNCE),
- );
+ this.editor.onDidChangeModelContent(debounce(this.onFileChange.bind(this), this.debounceValue));
},
beforeDestroy() {
this.editor.dispose();
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 5aae1812de3..4a78cbacec0 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -35,16 +35,20 @@ export default {
},
highlightedContent() {
let highlightedContent;
+ let { language } = this;
if (this.hljs) {
- if (!this.language) {
- highlightedContent = this.hljs.highlightAuto(this.content).value;
+ if (!language) {
+ const hljsHighlightAuto = this.hljs.highlightAuto(this.content);
+
+ highlightedContent = hljsHighlightAuto.value;
+ language = hljsHighlightAuto.language;
} else if (this.languageDefinition) {
highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
}
}
- return wrapLines(highlightedContent);
+ return wrapLines(highlightedContent, language);
},
},
watch: {
@@ -110,7 +114,7 @@ export default {
data-qa-selector="blob_viewer_file_content"
>
<line-numbers :lines="lineNumbers" />
- <pre class="code gl-pb-0!"><code v-safe-html="highlightedContent"></code>
+ <pre class="code highlight gl-pb-0!"><code v-safe-html="highlightedContent"></code>
</pre>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
index e64e564bf61..d726a8a55ff 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
@@ -1,11 +1,13 @@
-export const wrapLines = (content) => {
+export const wrapLines = (content, language) => {
+ const isValidLanguage = /^[a-z\d\-_]+$/.test(language); // To prevent the possibility of a vulnerability we only allow languages that contain alphanumeric characters ([a-z\d), dashes (-) or underscores (_).
+
return (
content &&
content
.split('\n')
.map((line, i) => {
let formattedLine;
- const idAttribute = `id="LC${i + 1}"`;
+ const attributes = `id="LC${i + 1}" lang="${isValidLanguage ? language : ''}"`;
if (line.includes('<span class="hljs') && !line.includes('</span>')) {
/**
@@ -14,9 +16,9 @@ export const wrapLines = (content) => {
* example (before): <span class="hljs-code">```bash
* example (after): <span id="LC67" class="hljs-code">```bash
*/
- formattedLine = line.replace(/(?=class="hljs)/, `${idAttribute} `);
+ formattedLine = line.replace(/(?=class="hljs)/, `${attributes} `);
} else {
- formattedLine = `<span ${idAttribute} class="line">${line}</span>`;
+ formattedLine = `<span ${attributes} class="line">${line}</span>`;
}
return formattedLine;
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index efb99eb0d94..d07f65cf5c1 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -1,30 +1,33 @@
<script>
/* This is a re-usable vue component for rendering a user avatar that
- does not need to link to the user's profile. The image and an optional
- tooltip can be configured by props passed to this component.
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
- Sample configuration:
+ Sample configuration:
- <user-avatar-image
- :lazy="true"
- :img-src="userAvatarSrc"
- :img-alt="tooltipText"
- :tooltip-text="tooltipText"
- tooltip-placement="top"
- />
+ <user-avatar-image
+ lazy
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
-*/
+ */
-import { GlTooltip } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
-import { placeholderImage } from '../../../lazy_loader';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import UserAvatarImageNew from './user_avatar_image_new.vue';
+import UserAvatarImageOld from './user_avatar_image_old.vue';
export default {
name: 'UserAvatarImage',
components: {
- GlTooltip,
+ UserAvatarImageNew,
+ UserAvatarImageOld,
},
+ mixins: [glFeatureFlagMixin()],
props: {
lazy: {
type: Boolean,
@@ -62,51 +65,14 @@ export default {
default: 'top',
},
},
- computed: {
- // API response sends null when gravatar is disabled and
- // we provide an empty string when we use it inside user avatar link.
- // In both cases we should render the defaultAvatarUrl
- sanitizedSource() {
- let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
- // Only adds the width to the URL if its not a base64 data image
- if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
- baseSrc += `?width=${this.size}`;
- return baseSrc;
- },
- resultantSrcAttribute() {
- return this.lazy ? placeholderImage : this.sanitizedSource;
- },
- avatarSizeClass() {
- return `s${this.size}`;
- },
- },
};
</script>
<template>
- <span>
- <img
- ref="userAvatarImage"
- :class="{
- lazy: lazy,
- [avatarSizeClass]: true,
- [cssClasses]: true,
- }"
- :src="resultantSrcAttribute"
- :width="size"
- :height="size"
- :alt="imgAlt"
- :data-src="sanitizedSource"
- class="avatar"
- />
- <gl-tooltip
- v-if="tooltipText || $slots.default"
- :target="() => $refs.userAvatarImage"
- :placement="tooltipPlacement"
- boundary="window"
- class="js-user-avatar-image-tooltip"
- >
- <slot> {{ tooltipText }} </slot>
- </gl-tooltip>
- </span>
+ <user-avatar-image-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props">
+ <slot></slot>
+ </user-avatar-image-new>
+ <user-avatar-image-old v-else v-bind="$props">
+ <slot></slot>
+ </user-avatar-image-old>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
new file mode 100644
index 00000000000..f52a3471ea4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
@@ -0,0 +1,106 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar that
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar
+ lazy
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+ */
+
+import { GlTooltip, GlAvatar } from '@gitlab/ui';
+import defaultAvatarUrl from 'images/no_avatar.png';
+import { __ } from '~/locale';
+import { placeholderImage } from '../../../lazy_loader';
+
+export default {
+ name: 'UserAvatarImageNew',
+ components: {
+ GlTooltip,
+ GlAvatar,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: __('user avatar'),
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+ computed: {
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside user avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ sanitizedSource() {
+ let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ // Only adds the width to the URL if its not a base64 data image
+ if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
+ baseSrc += `?width=${this.size}`;
+ return baseSrc;
+ },
+ resultantSrcAttribute() {
+ return this.lazy ? placeholderImage : this.sanitizedSource;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-avatar
+ ref="userAvatar"
+ :class="{
+ lazy: lazy,
+ [cssClasses]: true,
+ }"
+ :src="resultantSrcAttribute"
+ :data-src="sanitizedSource"
+ :size="size"
+ :alt="imgAlt"
+ />
+
+ <gl-tooltip
+ :target="() => $refs.userAvatar.$el"
+ :placement="tooltipPlacement"
+ boundary="window"
+ >
+ <slot> {{ tooltipText }}</slot>
+ </gl-tooltip>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
new file mode 100644
index 00000000000..bca10c76038
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
@@ -0,0 +1,110 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar that
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-image
+ lazy
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+ */
+
+import { GlTooltip } from '@gitlab/ui';
+import defaultAvatarUrl from 'images/no_avatar.png';
+import { __ } from '~/locale';
+import { placeholderImage } from '../../../lazy_loader';
+
+export default {
+ name: 'UserAvatarImageOld',
+ components: {
+ GlTooltip,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: __('user avatar'),
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+ computed: {
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside user avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ sanitizedSource() {
+ let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ // Only adds the width to the URL if its not a base64 data image
+ if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
+ baseSrc += `?width=${this.size}`;
+ return baseSrc;
+ },
+ resultantSrcAttribute() {
+ return this.lazy ? placeholderImage : this.sanitizedSource;
+ },
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <img
+ ref="userAvatarImage"
+ :class="{
+ lazy: lazy,
+ [avatarSizeClass]: true,
+ [cssClasses]: true,
+ }"
+ :src="resultantSrcAttribute"
+ :width="size"
+ :height="size"
+ :alt="imgAlt"
+ :data-src="sanitizedSource"
+ class="avatar"
+ />
+ <gl-tooltip
+ :target="() => $refs.userAvatarImage"
+ :placement="tooltipPlacement"
+ boundary="window"
+ >
+ <slot> {{ tooltipText }}</slot>
+ </gl-tooltip>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
index 04423aac651..887deff17c9 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -17,18 +17,17 @@
*/
-import { GlLink, GlTooltipDirective } from '@gitlab/ui';
-import userAvatarImage from './user_avatar_image.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import UserAvatarLinkNew from './user_avatar_link_new.vue';
+import UserAvatarLinkOld from './user_avatar_link_old.vue';
export default {
name: 'UserAvatarLink',
components: {
- GlLink,
- userAvatarImage,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ UserAvatarLinkNew,
+ UserAvatarLinkOld,
},
+ mixins: [glFeatureFlagMixin()],
props: {
lazy: {
type: Boolean,
@@ -76,36 +75,21 @@ export default {
default: '',
},
},
- computed: {
- shouldShowUsername() {
- return this.username.length > 0;
- },
- avatarTooltipText() {
- return this.shouldShowUsername ? '' : this.tooltipText;
- },
- },
};
</script>
<template>
- <gl-link :href="linkHref" class="user-avatar-link">
- <user-avatar-image
- :img-src="imgSrc"
- :img-alt="imgAlt"
- :css-classes="imgCssClasses"
- :size="imgSize"
- :tooltip-text="avatarTooltipText"
- :tooltip-placement="tooltipPlacement"
- :lazy="lazy"
- >
- <slot></slot> </user-avatar-image
- ><span
- v-if="shouldShowUsername"
- v-gl-tooltip
- :title="tooltipText"
- :tooltip-placement="tooltipPlacement"
- class="js-user-avatar-link-username"
- >{{ username }}</span
- ><slot name="avatar-badge"></slot>
- </gl-link>
+ <user-avatar-link-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props">
+ <slot></slot>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
+ </user-avatar-link-new>
+
+ <user-avatar-link-old v-else v-bind="$props">
+ <slot></slot>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
+ </user-avatar-link-old>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
new file mode 100644
index 00000000000..3b459569274
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
@@ -0,0 +1,117 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar wrapped in
+ a clickable link (likely to the user's profile). The link, image, and
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-link
+ :link-href="userProfileUrl"
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :img-size="20"
+ :tooltip-text="tooltipText"
+ :tooltip-placement="top"
+ :username="username"
+ />
+
+*/
+
+import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui';
+import UserAvatarImage from './user_avatar_image.vue';
+
+export default {
+ name: 'UserAvatarLinkNew',
+ components: {
+ UserAvatarImage,
+ GlAvatarLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ linkHref: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgCssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ username: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ shouldShowUsername() {
+ return this.username.length > 0;
+ },
+ avatarTooltipText() {
+ return this.shouldShowUsername ? '' : this.tooltipText;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-link :href="linkHref" class="user-avatar-link">
+ <user-avatar-image
+ :img-src="imgSrc"
+ :img-alt="imgAlt"
+ :css-classes="imgCssClasses"
+ :size="imgSize"
+ :tooltip-text="avatarTooltipText"
+ :tooltip-placement="tooltipPlacement"
+ :lazy="lazy"
+ >
+ <slot></slot>
+ </user-avatar-image>
+
+ <span
+ v-if="shouldShowUsername"
+ v-gl-tooltip
+ :title="tooltipText"
+ :tooltip-placement="tooltipPlacement"
+ class="gl-ml-3"
+ data-testid="user-avatar-link-username"
+ >
+ {{ username }}
+ </span>
+
+ <slot name="avatar-badge"></slot>
+ </gl-avatar-link>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue
new file mode 100644
index 00000000000..c2e46e61e1b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue
@@ -0,0 +1,117 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar wrapped in
+ a clickable link (likely to the user's profile). The link, image, and
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-link
+ :link-href="userProfileUrl"
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :img-size="20"
+ :tooltip-text="tooltipText"
+ :tooltip-placement="top"
+ :username="username"
+ />
+
+*/
+
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
+import UserAvatarImage from './user_avatar_image.vue';
+
+export default {
+ name: 'UserAvatarLinkOld',
+ components: {
+ GlLink,
+ UserAvatarImage,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ linkHref: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgCssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ username: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ shouldShowUsername() {
+ return this.username.length > 0;
+ },
+ avatarTooltipText() {
+ return this.shouldShowUsername ? '' : this.tooltipText;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-link :href="linkHref" class="user-avatar-link">
+ <user-avatar-image
+ :img-src="imgSrc"
+ :img-alt="imgAlt"
+ :css-classes="imgCssClasses"
+ :size="imgSize"
+ :tooltip-text="avatarTooltipText"
+ :tooltip-placement="tooltipPlacement"
+ :lazy="lazy"
+ >
+ <slot></slot>
+ </user-avatar-image>
+
+ <span
+ v-if="shouldShowUsername"
+ v-gl-tooltip
+ :title="tooltipText"
+ :tooltip-placement="tooltipPlacement"
+ data-testid="user-avatar-link-username"
+ >
+ {{ username }}
+ </span>
+ <slot name="avatar-badge"></slot>
+ </gl-link>
+ </span>
+</template>
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 05e0c3b0be3..41507ca94e2 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
@@ -116,7 +116,7 @@ export default {
<div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status">
<span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
- <div v-if="user.bot" class="gl-text-blue-500">
+ <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
<gl-icon name="question" />
<gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
<gl-sprintf :message="__('Learn more about %{username}')">
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
index b85cae0c64f..9df5254155e 100644
--- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -1,4 +1,5 @@
<script>
+import { debounce } from 'lodash';
import {
GlDropdown,
GlDropdownForm,
@@ -6,11 +7,14 @@ import {
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
+ GlTooltipDirective,
} from '@gitlab/ui';
-import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
-import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants';
+import { IssuableType } from '~/issues/constants';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { participantsQueries, userSearchQueries } from '~/sidebar/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
export default {
i18n: {
@@ -25,6 +29,9 @@ export default {
SidebarParticipant,
GlLoadingIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
headerText: {
type: String,
@@ -58,13 +65,18 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: IssuableType.Issue,
},
isEditing: {
type: Boolean,
required: false,
default: true,
},
+ issuableId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -89,28 +101,35 @@ export default {
};
},
update(data) {
- return data.workspace?.issuable?.participants.nodes;
+ return data.workspace?.issuable?.participants.nodes.map((node) => ({
+ ...node,
+ canMerge: false,
+ }));
},
error() {
this.$emit('error');
},
},
searchUsers: {
- query: searchUsers,
+ query() {
+ return userSearchQueries[this.issuableType].query;
+ },
variables() {
- return {
- fullPath: this.fullPath,
- search: this.search,
- first: 20,
- };
+ return this.searchUsersVariables;
},
skip() {
return !this.isEditing;
},
update(data) {
- return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || [];
+ return (
+ data.workspace?.users?.nodes
+ .filter((x) => x?.user)
+ .map((node) => ({
+ ...node.user,
+ canMerge: node.mergeRequestInteraction?.canMerge || false,
+ })) || []
+ );
},
- debounce: ASSIGNEES_DEBOUNCE_DELAY,
error() {
this.$emit('error');
this.isSearching = false;
@@ -121,6 +140,23 @@ export default {
},
},
computed: {
+ isMergeRequest() {
+ return this.issuableType === IssuableType.MergeRequest;
+ },
+ searchUsersVariables() {
+ const variables = {
+ fullPath: this.fullPath,
+ search: this.search,
+ first: 20,
+ };
+ if (!this.isMergeRequest) {
+ return variables;
+ }
+ return {
+ ...variables,
+ mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId),
+ };
+ },
isLoading() {
return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading;
},
@@ -135,8 +171,8 @@ export default {
// TODO this de-duplication is temporary (BE fix required)
// https://gitlab.com/gitlab-org/gitlab/-/issues/327822
- const mergedSearchResults = filteredParticipants
- .concat(this.searchUsers)
+ const mergedSearchResults = this.searchUsers
+ .concat(filteredParticipants)
.reduce(
(acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]),
[],
@@ -179,6 +215,7 @@ export default {
return this.selectedFiltered.length === 0;
},
},
+
watch: {
// We need to add this watcher to track the moment when user is alredy typing
// but query is still not started due to debounce
@@ -188,15 +225,21 @@ export default {
}
},
},
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
methods: {
selectAssignee(user) {
let selected = [...this.value];
if (!this.allowMultipleAssignees) {
selected = [user];
+ this.$emit('input', selected);
+ this.$refs.dropdown.hide();
+ this.$emit('toggle');
} else {
selected.push(user);
+ this.$emit('input', selected);
}
- this.$emit('input', selected);
},
unselect(name) {
const selected = this.value.filter((user) => user.username !== name);
@@ -205,6 +248,9 @@ export default {
focusSearch() {
this.$refs.search.focusInput();
},
+ showDropdown() {
+ this.$refs.dropdown.show();
+ },
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
},
@@ -216,22 +262,37 @@ export default {
const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
if (currentUser) {
+ currentUser.canMerge = this.currentUser.canMerge;
const index = usersCopy.indexOf(currentUser);
usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
}
return usersCopy;
},
+ setSearchKey(value) {
+ this.search = value.trim();
+ },
+ tooltipText(user) {
+ if (!this.isMergeRequest) {
+ return '';
+ }
+ return user.canMerge ? '' : __('Cannot merge');
+ },
},
};
</script>
<template>
- <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')">
+ <gl-dropdown ref="dropdown" :text="text" @toggle="$emit('toggle')" @shown="focusSearch">
<template #header>
<p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
<gl-dropdown-divider />
- <gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" />
+ <gl-search-box-by-type
+ ref="search"
+ :value="search"
+ class="js-dropdown-input-field"
+ @input="debouncedSearchKeyUpdate"
+ />
</template>
<gl-dropdown-form class="gl-relative gl-min-h-7">
<gl-loading-icon
@@ -247,7 +308,7 @@ export default {
:is-checked="selectedIsEmpty"
:is-check-centered="true"
data-testid="unassign"
- @click="$emit('input', [])"
+ @click.native.capture.stop="$emit('input', [])"
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
$options.i18n.unassigned
@@ -258,27 +319,44 @@ export default {
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
+ v-gl-tooltip.left.viewport
+ :title="tooltipText(item)"
+ boundary="viewport"
is-checked
is-check-centered
data-testid="selected-participant"
- @click.stop="unselect(item.username)"
+ @click.native.capture.stop="unselect(item.username)"
>
- <sidebar-participant :user="item" />
+ <sidebar-participant :user="item" :issuable-type="issuableType" />
</gl-dropdown-item>
<template v-if="showCurrentUser">
<gl-dropdown-divider />
- <gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)">
- <sidebar-participant :user="currentUser" class="gl-pl-6!" />
+ <gl-dropdown-item
+ data-testid="current-user"
+ @click.native.capture.stop="selectAssignee(currentUser)"
+ >
+ <sidebar-participant
+ :user="currentUser"
+ :issuable-type="issuableType"
+ class="gl-pl-6!"
+ />
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
+ v-gl-tooltip.left.viewport
+ :title="tooltipText(unselectedUser)"
+ boundary="viewport"
data-testid="unselected-participant"
- @click="selectAssignee(unselectedUser)"
+ @click.native.capture.stop="selectAssignee(unselectedUser)"
>
- <sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
+ <sidebar-participant
+ :user="unselectedUser"
+ :issuable-type="issuableType"
+ class="gl-pl-6!"
+ />
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!">
{{ __('No matching results') }}
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 82022d1f4d6..199516b3eb3 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -8,6 +8,7 @@ import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
const KEY_EDIT = 'edit';
const KEY_WEB_IDE = 'webide';
const KEY_GITPOD = 'gitpod';
+const KEY_PIPELINE_EDITOR = 'pipeline_editor';
export default {
components: {
@@ -64,6 +65,11 @@ export default {
required: false,
default: false,
},
+ showPipelineEditorButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
userPreferencesGitpodPath: {
type: String,
required: false,
@@ -79,6 +85,11 @@ export default {
required: false,
default: '',
},
+ pipelineEditorUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
webIdeUrl: {
type: String,
required: false,
@@ -117,14 +128,19 @@ export default {
},
data() {
return {
- selection: KEY_WEB_IDE,
+ selection: this.showPipelineEditorButton ? KEY_PIPELINE_EDITOR : KEY_WEB_IDE,
showEnableGitpodModal: false,
showForkModal: false,
};
},
computed: {
actions() {
- return [this.webIdeAction, this.editAction, this.gitpodAction].filter((action) => action);
+ return [
+ this.pipelineEditorAction,
+ this.webIdeAction,
+ this.editAction,
+ this.gitpodAction,
+ ].filter((action) => action);
},
editAction() {
if (!this.showEditButton) {
@@ -162,7 +178,7 @@ export default {
if (this.webIdeText) {
return this.webIdeText;
} else if (this.isBlob) {
- return __('Edit in Web IDE');
+ return __('Open in Web IDE');
} else if (this.isFork) {
return __('Edit fork in Web IDE');
}
@@ -202,6 +218,9 @@ export default {
};
},
gitpodActionText() {
+ if (this.isBlob) {
+ return __('Open in Gitpod');
+ }
return this.gitpodText || __('Gitpod');
},
computedShowGitpodButton() {
@@ -209,11 +228,28 @@ export default {
this.showGitpodButton && this.userPreferencesGitpodPath && this.userProfileEnableGitpodPath
);
},
+ pipelineEditorAction() {
+ if (!this.showPipelineEditorButton) {
+ return null;
+ }
+
+ const secondaryText = __('Edit, lint, and visualize your pipeline.');
+
+ return {
+ key: KEY_PIPELINE_EDITOR,
+ text: __('Edit in pipeline editor'),
+ secondaryText,
+ tooltip: secondaryText,
+ attrs: {
+ 'data-qa-selector': 'pipeline_editor_button',
+ },
+ href: this.pipelineEditorUrl,
+ };
+ },
gitpodAction() {
if (!this.computedShowGitpodButton) {
return null;
}
-
const handleOptions = this.gitpodEnabled
? { href: this.gitpodUrl }
: {
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
index b96ce0c43f7..45941174a62 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
@@ -58,7 +58,12 @@ export default {
<template>
<div>
<div class="title-container">
- <h2 v-safe-html="issuable.titleHtml || issuable.title" class="title qa-title" dir="auto"></h2>
+ <h1
+ v-safe-html="issuable.titleHtml || issuable.title"
+ class="title qa-title"
+ dir="auto"
+ data-testid="title"
+ ></h1>
<gl-button
v-if="enableEdit"
v-gl-tooltip.bottom
diff --git a/app/assets/javascripts/webpack_non_compiled_placeholder.js b/app/assets/javascripts/webpack_non_compiled_placeholder.js
index 55ac2f0be6a..af671e72129 100644
--- a/app/assets/javascripts/webpack_non_compiled_placeholder.js
+++ b/app/assets/javascripts/webpack_non_compiled_placeholder.js
@@ -1,3 +1,4 @@
+/* globals LIVE_RELOAD */
const div = document.createElement('div');
Object.assign(div.style, {
@@ -15,6 +16,10 @@ Object.assign(div.style, {
'text-align': 'center',
});
+const reloadMessage = LIVE_RELOAD
+ ? 'You have live_reload enabled, the page will reload automatically when complete.'
+ : 'You have live_reload disabled, the page will reload automatically in a few seconds.';
+
div.innerHTML = `
<!-- 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">
@@ -30,9 +35,15 @@ div.innerHTML = `
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>
+ ${reloadMessage}<br />
+ If it doesn't, please <a href="">reload the page manually</a>.
</p>
`;
document.body.append(div);
+
+if (!LIVE_RELOAD) {
+ setTimeout(() => {
+ window.location.reload();
+ }, 5000);
+}
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
new file mode 100644
index 00000000000..942677bb937
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -0,0 +1,62 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import workItemQuery from '../graphql/work_item.query.graphql';
+import ItemTitle from './item_title.vue';
+
+export default {
+ components: {
+ GlModal,
+ ItemTitle,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ workItemId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ workItem: {},
+ };
+ },
+ apollo: {
+ workItem: {
+ query: workItemQuery,
+ variables() {
+ return {
+ id: this.workItemId,
+ };
+ },
+ update(data) {
+ return data.workItem;
+ },
+ skip() {
+ return !this.workItemId;
+ },
+ error() {
+ this.$emit(
+ 'error',
+ s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
+ );
+ },
+ },
+ },
+ computed: {
+ workItemTitle() {
+ return this.workItem?.title;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')">
+ <item-title class="gl-m-0!" :initial-title="workItemTitle" />
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
index 2f302dae7d7..9312d1c582b 100644
--- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
@@ -1,16 +1,16 @@
#import './widget.fragment.graphql'
-mutation createWorkItem($input: LocalCreateWorkItemInput) {
- localCreateWorkItem(input: $input) @client {
+mutation createWorkItem($input: WorkItemCreateInput!) {
+ workItemCreate(input: $input) {
workItem {
id
- type
- widgets {
+ title
+ workItemType {
+ id
+ }
+ widgets @client {
nodes {
...WidgetBase
- ... on LocalTitleWidget {
- contentText
- }
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 676fffb12d8..28328a840cf 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -10,29 +10,28 @@ export function createApolloProvider() {
const defaultClient = createDefaultClient(resolvers, {
typeDefs,
+ cacheConfig: {
+ possibleTypes: {
+ LocalWorkItemWidget: ['LocalTitleWidget'],
+ },
+ },
});
defaultClient.cache.writeQuery({
query: workItemQuery,
variables: {
- id: '1',
+ id: 'gid://gitlab/WorkItem/1',
},
data: {
- workItem: {
+ localWorkItem: {
__typename: 'LocalWorkItem',
- id: '1',
+ id: 'gid://gitlab/WorkItem/1',
type: 'FEATURE',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ title: 'Test Work Item',
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
- nodes: [
- {
- __typename: 'LocalTitleWidget',
- type: 'TITLE',
- enabled: true,
- // eslint-disable-next-line @gitlab/require-i18n-strings
- contentText: 'Test Work Item Title',
- },
- ],
+ nodes: [],
},
},
},
diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js
index 63d5234d083..fb74e27f840 100644
--- a/app/assets/javascripts/work_items/graphql/resolvers.js
+++ b/app/assets/javascripts/work_items/graphql/resolvers.js
@@ -1,53 +1,24 @@
-import { uuids } from '~/lib/utils/uuids';
import workItemQuery from './work_item.query.graphql';
export const resolvers = {
Mutation: {
- localCreateWorkItem(_, { input }, { cache }) {
- const id = uuids()[0];
- const workItem = {
- __typename: 'LocalWorkItem',
- type: 'FEATURE',
- id,
- widgets: {
- __typename: 'LocalWorkItemWidgetConnection',
- nodes: [
- {
- __typename: 'LocalTitleWidget',
- type: 'TITLE',
- enabled: true,
- contentText: input.title,
- },
- ],
- },
- };
-
- cache.writeQuery({ query: workItemQuery, variables: { id }, data: { workItem } });
-
- return {
- __typename: 'LocalCreateWorkItemPayload',
- workItem,
- };
- },
-
localUpdateWorkItem(_, { input }, { cache }) {
- const workItemTitle = {
- __typename: 'LocalTitleWidget',
- type: 'TITLE',
- enabled: true,
- contentText: input.title,
- };
const workItem = {
__typename: 'LocalWorkItem',
type: 'FEATURE',
id: input.id,
+ title: input.title,
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
- nodes: [workItemTitle],
+ nodes: [],
},
};
- cache.writeQuery({ query: workItemQuery, variables: { id: input.id }, data: { workItem } });
+ cache.writeQuery({
+ query: workItemQuery,
+ variables: { id: input.id },
+ data: { localWorkItem: workItem },
+ });
return {
__typename: 'LocalUpdateWorkItemPayload',
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index 177eea00322..9b4811203f5 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -22,14 +22,10 @@ type LocalWorkItemWidgetConnection {
pageInfo: PageInfo!
}
-type LocalTitleWidget implements LocalWorkItemWidget {
- type: LocalWidgetType!
- contentText: String!
-}
-
type LocalWorkItem {
id: ID!
type: LocalWorkItemType!
+ title: String!
widgets: [LocalWorkItemWidgetConnection]
}
@@ -51,7 +47,7 @@ type LocalUpdateWorkItemPayload {
}
extend type Query {
- workItem(id: ID!): LocalWorkItem!
+ localWorkItem(id: ID!): LocalWorkItem!
}
extend type Mutation {
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
index f0563f099b2..efb1ed8d6df 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
@@ -1,16 +1,16 @@
#import './widget.fragment.graphql'
-mutation updateWorkItem($input: LocalUpdateWorkItemInput) {
- localUpdateWorkItem(input: $input) @client {
+mutation workItemUpdate($input: WorkItemUpdateInput!) {
+ workItemUpdate(input: $input) {
workItem {
id
- type
- widgets {
+ title
+ workItemType {
+ id
+ }
+ widgets @client {
nodes {
...WidgetBase
- ... on LocalTitleWidget {
- contentText
- }
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
index 9f173f7c302..b32cb4f28fb 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -1,15 +1,15 @@
#import './widget.fragment.graphql'
query WorkItem($id: ID!) {
- workItem(id: $id) @client {
+ workItem(id: $id) {
id
- type
- widgets {
+ title
+ workItemType {
+ id
+ }
+ widgets @client {
nodes {
...WidgetBase
- ... on LocalTitleWidget {
- contentText
- }
}
}
}
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
index 6c3bcf8f6a8..cc90cedb110 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -1,6 +1,8 @@
<script>
import { GlButton, GlAlert, GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import workItemQuery from '../graphql/work_item.query.graphql';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
@@ -67,21 +69,45 @@ export default {
variables: {
input: {
title: this.title,
+ projectPath: this.fullPath,
+ workItemTypeId: this.selectedWorkItemType?.id,
},
},
+ update(store, { data: { workItemCreate } }) {
+ const { id, title, workItemType } = workItemCreate.workItem;
+
+ store.writeQuery({
+ query: workItemQuery,
+ variables: {
+ id,
+ },
+ data: {
+ workItem: {
+ __typename: 'WorkItem',
+ id,
+ title,
+ workItemType,
+ widgets: {
+ __typename: 'LocalWorkItemWidgetConnection',
+ nodes: [],
+ },
+ },
+ },
+ });
+ },
});
const {
data: {
- localCreateWorkItem: {
- workItem: { id },
+ workItemCreate: {
+ workItem: { id, type },
},
},
} = response;
if (!this.isModal) {
- this.$router.push({ name: 'workItem', params: { id } });
+ this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } });
} else {
- this.$emit('onCreate', this.title);
+ this.$emit('onCreate', { id, title: this.title, type });
}
} catch {
this.error = s__(
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
index 4262e169655..32b6fc231a8 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -1,9 +1,10 @@
<script>
-import { GlAlert } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking';
import workItemQuery from '../graphql/work_item.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
-import { widgetTypes, WI_TITLE_TRACK_LABEL } from '../constants';
+import { WI_TITLE_TRACK_LABEL } from '../constants';
import ItemTitle from '../components/item_title.vue';
@@ -14,6 +15,7 @@ export default {
components: {
ItemTitle,
GlAlert,
+ GlLoadingIcon,
},
mixins: [trackingMixin],
props: {
@@ -24,7 +26,7 @@ export default {
},
data() {
return {
- workItem: null,
+ workItem: {},
error: false,
};
},
@@ -33,7 +35,7 @@ export default {
query: workItemQuery,
variables() {
return {
- id: this.id,
+ id: this.gid,
};
},
},
@@ -47,19 +49,19 @@ export default {
property: '[type_work_item]',
};
},
- titleWidgetData() {
- return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title);
+ gid() {
+ return convertToGraphQLId('WorkItem', this.id);
},
},
methods: {
- async updateWorkItem(title) {
+ async updateWorkItem(updatedTitle) {
try {
await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: {
input: {
- id: this.id,
- title,
+ id: this.gid,
+ title: updatedTitle,
},
},
});
@@ -79,12 +81,18 @@ export default {
}}</gl-alert>
<!-- Title widget placeholder -->
<div>
- <item-title
- v-if="titleWidgetData"
- :initial-title="titleWidgetData.contentText"
- data-testid="title"
- @title-changed="updateWorkItem"
+ <gl-loading-icon
+ v-if="$apollo.queries.workItem.loading"
+ size="md"
+ data-testid="loading-types"
/>
+ <template v-else>
+ <item-title
+ :initial-title="workItem.title"
+ data-testid="title"
+ @title-changed="updateWorkItem"
+ />
+ </template>
</div>
</section>
</template>
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 7b4f68e7a44..e06c71dccf0 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -360,27 +360,10 @@
}
> li {
- // TODO: Remove this block once all sidebar badges use gl_badge_tag
- // https://gitlab.com/gitlab-org/gitlab/-/issues/350061
- .badge.badge-pill:not(.gl-badge) {
- @include gl-rounded-lg;
- @include gl-py-1;
- @include gl-px-3;
- background-color: $blue-100;
- color: $blue-700;
- }
-
&.active {
.sidebar-sub-level-items:not(.is-fly-out-only) {
display: block;
}
-
- // TODO: Remove this block once all sidebar badges use gl_badge_tag
- // https://gitlab.com/gitlab-org/gitlab/-/issues/350061
- .badge.badge-pill:not(.gl-badge) {
- @include gl-font-weight-normal;
- color: $blue-700;
- }
}
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 9387500e66f..e378fcb6129 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -377,10 +377,6 @@ span.idiff {
color: $gl-text-color;
}
- .file-actions .ide-edit-button {
- z-index: 2;
- }
-
@include media-breakpoint-down(md) {
.file-actions {
margin-top: $gl-padding-8;
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 122c605e603..a80643e695b 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -87,7 +87,7 @@ td.line-numbers {
}
.project-highlight-puc .unicode-bidi::before {
- content: '�';
+ content: '\FFFD';
cursor: pointer;
text-decoration: underline wavy $red-500;
}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 1e51bf3d974..1caf02937d5 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -439,6 +439,12 @@
.na {
color: inherit;
}
+
+ // Rouge `Comment` token (quoted text in email body)
+ .c {
+ color: $gl-grayish-blue;
+ font-style: italic;
+ }
}
}
}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index feedc40b487..b1e44a81267 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -158,8 +158,8 @@
}
hr {
- // Darken 'whitesmoke' a bit to make it more visible in note bodies
- border-color: darken($gray-normal, 8%);
+ border-color: rgba($black, 0.15);
+
margin: 10px 0;
}
diff --git a/app/assets/stylesheets/notify.scss b/app/assets/stylesheets/notify.scss
index feb4ea77e58..2d501781119 100644
--- a/app/assets/stylesheets/notify.scss
+++ b/app/assets/stylesheets/notify.scss
@@ -1,24 +1,4 @@
-@import 'framework/mixins';
-@import 'framework/variables';
-
-img {
- max-width: 100%;
- height: auto;
-}
-
-p.details {
- font-style: italic;
- color: $gray-500;
-}
-
-.footer > p {
- font-size: small;
- color: $gray-500;
-}
-
-pre.commit-message {
- white-space: pre-wrap;
-}
+@import 'notify_base';
.gl-label-scoped {
border: 2px solid currentColor;
@@ -40,6 +20,11 @@ pre.commit-message {
color: $gl-text-color;
}
+.gl-label-text-scoped {
+ padding: 0 5px;
+ color: $gl-text-color;
+}
+
.content {
.markdown-code-block pre.code {
padding: $gl-padding-8 $input-horizontal-padding;
@@ -47,6 +32,4 @@ pre.commit-message {
border: 1px solid $gray-100;
border-radius: $border-radius-small;
}
-
- @include email-code-block;
}
diff --git a/app/assets/stylesheets/notify_base.scss b/app/assets/stylesheets/notify_base.scss
new file mode 100644
index 00000000000..8c6f9a27077
--- /dev/null
+++ b/app/assets/stylesheets/notify_base.scss
@@ -0,0 +1,25 @@
+@import 'framework/mixins';
+@import 'framework/variables';
+
+img {
+ max-width: 100%;
+ height: auto;
+}
+
+p.details {
+ font-style: italic;
+ color: $gray-500;
+}
+
+.footer > p {
+ font-size: small;
+ color: $gray-500;
+}
+
+pre.commit-message {
+ white-space: pre-wrap;
+}
+
+.content {
+ @include email-code-block;
+}
diff --git a/app/assets/stylesheets/notify_enhanced.scss b/app/assets/stylesheets/notify_enhanced.scss
new file mode 100644
index 00000000000..5df5a8592bf
--- /dev/null
+++ b/app/assets/stylesheets/notify_enhanced.scss
@@ -0,0 +1,68 @@
+// Import a subset of the GitLab UI framework:
+// keep parts that affect elements that can appear in emails;
+// omit Bootstrap Reboot since it adds unnecessary styles to every element.
+@import 'notify_base';
+@import 'bootstrap/scss/functions';
+@import 'bootstrap/scss/variables';
+@import 'bootstrap/scss/mixins';
+@import 'bootstrap/scss/code';
+@import '@gitlab/ui/src/scss/variables';
+@import '@gitlab/ui/src/scss/utility-mixins/index';
+@import '@gitlab/ui/src/scss/components';
+@import 'bootstrap_migration';
+@import 'framework/common';
+@import 'framework/gfm';
+@import 'framework/kbd';
+@import 'framework/tables';
+@import 'framework/typography';
+@import 'framework/emojis';
+
+body {
+ font-family: $regular-font;
+ font-size: inherit;
+}
+
+a {
+ text-decoration: none;
+}
+
+.content {
+ .md {
+ padding: 1rem 0;
+ }
+
+ hr {
+ border: 1px solid #e1e1e1;
+ }
+
+ blockquote {
+ border-top-width: 0;
+ border-bottom-width: 0;
+ border-right-width: 0;
+
+ &:dir(rtl) {
+ border-left-width: 0;
+ border-right-width: inherit;
+ }
+ }
+
+ table {
+ border-collapse: collapse;
+ }
+
+ .diff-table.code,
+ table.code {
+ width: auto;
+
+ td {
+ padding: inherit;
+
+ pre {
+ background-color: inherit;
+ margin: 0;
+ padding: 0;
+ border: inherit;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 63e951be698..34a3d936a67 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -745,6 +745,10 @@ $tabs-holder-z-index: 250;
}
}
+.mr-section-container .resize-observer > object {
+ height: 0;
+}
+
// TODO: Move to GitLab UI
.mr-extenson-scrim {
background: linear-gradient(to bottom, rgba($gray-light, 0), rgba($gray-light, 1));
@@ -753,3 +757,7 @@ $tabs-holder-z-index: 250;
background: linear-gradient(to bottom, rgba(#333, 0), rgba(#333, 1));
}
}
+
+.attention-request-sidebar-popover {
+ z-index: 999;
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index fa07d29b536..c00af802c06 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -108,12 +108,15 @@
.merge-icon {
color: $orange-400;
position: absolute;
- bottom: 0;
- right: 0;
filter: drop-shadow(0 0 0.5px $white) drop-shadow(0 0 1px $white) drop-shadow(0 0 2px $white);
}
}
+.assignee .merge-icon {
+ top: calc(50% + 0.25rem);
+ left: 1.275rem;
+}
+
.reviewer .merge-icon {
bottom: -3px;
right: -3px;
@@ -399,7 +402,7 @@
/*
This change should be temporary, because the DOM currently gets
generated from a ruby definition in `app/helpers/button_helper.rb`.
- As soon as the `copy to clipboard` button will be transfered to
+ As soon as the `copy to clipboard` button will be transferred to
Vue this should be adjusted as well.
*/
flex: 1;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index ac3d4dad585..8034389adc8 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -134,47 +134,6 @@
}
}
-.fork-thumbnail {
- width: calc((100% / 2) - #{$gl-padding * 2});
-
- @include media-breakpoint-up(md) {
- width: calc((100% / 4) - #{$gl-padding * 2});
- }
-
- @include media-breakpoint-up(lg) {
- width: calc((100% / 5) - #{$gl-padding * 2});
- }
-
- &:hover:not(.disabled),
- &.forked {
- background-color: $blue-50;
- border-color: $blue-200;
- }
-
- .avatar-container,
- .identicon {
- float: none;
- margin-left: auto;
- margin-right: auto;
- }
-
- a.disabled {
- opacity: 0.3;
- cursor: not-allowed;
- }
-}
-
-.fork-thumbnail-container {
- display: flex;
- flex-wrap: wrap;
- margin-left: -$gl-padding;
- margin-right: -$gl-padding;
-
- > h5 {
- width: 100%;
- }
-}
-
.project-template {
> .form-group {
margin-bottom: 0;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 4c31cc6e111..c84a83c1fab 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -252,7 +252,8 @@ input[type='checkbox']:hover {
.btn-search,
.btn-success,
- .dropdown-menu-toggle {
+ .dropdown-menu-toggle,
+ .gl-new-dropdown {
width: 100%;
margin-top: 5px;
@@ -270,7 +271,8 @@ input[type='checkbox']:hover {
}
}
- .dropdown-menu-toggle {
+ .dropdown-menu-toggle,
+ .gl-new-dropdown {
@include media-breakpoint-up(sm) {
width: 180px;
margin-top: 0;
@@ -366,12 +368,13 @@ input[type='checkbox']:hover {
}
}
-// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling
-/* stylelint-disable property-no-vendor-prefix */
-input[type='search']::-webkit-search-decoration,
-input[type='search']::-webkit-search-cancel-button,
-input[type='search']::-webkit-search-results-button,
-input[type='search']::-webkit-search-results-decoration {
- -webkit-appearance: none;
+// Disable Webkit's search input styles
+input[type='search'] {
+ /* stylelint-disable-next-line property-no-vendor-prefix */
+ -webkit-appearance: textfield;
+
+ &::-webkit-search-cancel-button,
+ &::-webkit-search-results-button {
+ @include gl-display-none;
+ }
}
-/* stylelint-enable */
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 1397590cc31..00195f553dc 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -993,19 +993,6 @@ input {
.top-nav-toggle .dropdown-icon {
margin-right: 0.5rem;
}
-.tanuki-logo .tanuki-left-ear,
-.tanuki-logo .tanuki-right-ear,
-.tanuki-logo .tanuki-nose {
- fill: #e24329;
-}
-.tanuki-logo .tanuki-left-eye,
-.tanuki-logo .tanuki-right-eye {
- fill: #fc6d26;
-}
-.tanuki-logo .tanuki-left-cheek,
-.tanuki-logo .tanuki-right-cheek {
- fill: #fca326;
-}
.context-header {
position: relative;
margin-right: 2px;
@@ -1393,24 +1380,11 @@ input {
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
-.sidebar-top-level-items > li .badge.badge-pill:not(.gl-badge) {
- border-radius: 0.5rem;
- padding-top: 0.125rem;
- padding-bottom: 0.125rem;
- padding-left: 0.5rem;
- padding-right: 0.5rem;
- background-color: #064787;
- color: #9dc7f1;
-}
.sidebar-top-level-items
> li.active
.sidebar-sub-level-items:not(.is-fly-out-only) {
display: block;
}
-.sidebar-top-level-items > li.active .badge.badge-pill:not(.gl-badge) {
- font-weight: 400;
- color: #9dc7f1;
-}
.sidebar-top-level-items li > a.gl-link {
color: #fafafa;
}
@@ -1786,7 +1760,6 @@ body.gl-dark {
--border-color: #4f4f4f;
--white: #333;
--black: #fff;
- --black-normal: #fafafa;
--svg-status-bg: #333;
}
.nav-sidebar li a {
@@ -1824,6 +1797,9 @@ body.gl-dark .navbar-gitlab .navbar-sub-nav {
body.gl-dark .navbar-gitlab .nav > li {
color: #fafafa;
}
+body.gl-dark .navbar-gitlab .nav > li.header-search-new {
+ color: #fafafa;
+}
body.gl-dark .navbar-gitlab .nav > li > a .notification-dot {
border: 2px solid #fafafa;
}
@@ -1861,8 +1837,8 @@ body.gl-dark
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 svg.gl-search-box-by-type-search-icon {
+ color: rgba(250, 250, 250, 0.8);
}
body.gl-dark .header-search input {
background-color: transparent;
@@ -2017,7 +1993,6 @@ body.gl-dark {
--border-color: #4f4f4f;
--white: #333;
--black: #fff;
- --black-normal: #fafafa;
--svg-status-bg: #333;
}
.tab-width-8 {
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 0d35c400676..6d66e207bdc 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -978,19 +978,6 @@ input {
.top-nav-toggle .dropdown-icon {
margin-right: 0.5rem;
}
-.tanuki-logo .tanuki-left-ear,
-.tanuki-logo .tanuki-right-ear,
-.tanuki-logo .tanuki-nose {
- fill: #e24329;
-}
-.tanuki-logo .tanuki-left-eye,
-.tanuki-logo .tanuki-right-eye {
- fill: #fc6d26;
-}
-.tanuki-logo .tanuki-left-cheek,
-.tanuki-logo .tanuki-right-cheek {
- fill: #fca326;
-}
.context-header {
position: relative;
margin-right: 2px;
@@ -1378,24 +1365,11 @@ input {
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
-.sidebar-top-level-items > li .badge.badge-pill:not(.gl-badge) {
- border-radius: 0.5rem;
- padding-top: 0.125rem;
- padding-bottom: 0.125rem;
- padding-left: 0.5rem;
- padding-right: 0.5rem;
- background-color: #cbe2f9;
- color: #0b5cad;
-}
.sidebar-top-level-items
> li.active
.sidebar-sub-level-items:not(.is-fly-out-only) {
display: block;
}
-.sidebar-top-level-items > li.active .badge.badge-pill:not(.gl-badge) {
- font-weight: 400;
- color: #0b5cad;
-}
.sidebar-top-level-items li > a.gl-link {
color: #303030;
}
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index c5cbe58ec27..213d1c013a0 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -514,19 +514,6 @@ label.label-bold {
.navbar-empty .brand-header-logo {
max-height: 100%;
}
-.tanuki-logo .tanuki-left-ear,
-.tanuki-logo .tanuki-right-ear,
-.tanuki-logo .tanuki-nose {
- fill: #e24329;
-}
-.tanuki-logo .tanuki-left-eye,
-.tanuki-logo .tanuki-right-eye {
- fill: #fc6d26;
-}
-.tanuki-logo .tanuki-left-cheek,
-.tanuki-logo .tanuki-right-cheek {
- fill: #fca326;
-}
input::-moz-placeholder {
color: #868686;
opacity: 1;
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index 9db134ffa65..3cb8c58a380 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -199,7 +199,6 @@ body.gl-dark {
--white: #{$white};
--black: #{$black};
- --black-normal: #{$black-normal};
--svg-status-bg: #{$white};
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index ec0928fc3d4..c6e29c7f8b0 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -64,6 +64,10 @@
> li {
color: $search-and-nav-links;
+ &.header-search-new {
+ color: $sidebar-text;
+ }
+
> a {
.notification-dot {
border: 2px solid $nav-svg-color;
@@ -151,10 +155,11 @@
background-color: rgba($search-and-nav-links, 0.3) !important;
}
- svg {
- color: rgba($search-and-nav-links, 0.8) !important;
+ svg.gl-search-box-by-type-search-icon {
+ color: rgba($search-and-nav-links, 0.8);
}
+
input {
background-color: transparent;
color: rgba($search-and-nav-links, 0.8);
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 8a4f9c32f9f..0511a179980 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -342,4 +342,32 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
margin-bottom: $gl-spacing-scale-12 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
}
}
+
/* End gitlab-ui#1709 */
+
+/*
+ * The below two styles will be moved to @gitlab/ui by
+ * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1750
+ */
+.gl-max-w-34 {
+ max-width: 34 * $grid-size;
+}
+
+.gl-max-w-80 {
+ max-width: 80 * $grid-size;
+}
+
+/*
+ * The below style will be moved to @gitlab/ui by
+ * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1751
+ */
+.gl-filter-blur-1 {
+ backdrop-filter: blur(2px);
+ /* stylelint-disable property-no-vendor-prefix */
+ -webkit-backdrop-filter: blur(2px); // still required by Safari
+}
+
+// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2708
+.gl-inset-border-l-3-red-600 {
+ box-shadow: inset $gl-border-size-3 0 0 0 $red-600;
+}
diff --git a/app/components/pajamas/component.rb b/app/components/pajamas/component.rb
new file mode 100644
index 00000000000..b05d93b680e
--- /dev/null
+++ b/app/components/pajamas/component.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Pajamas
+ class Component < ViewComponent::Base
+ private
+
+ # :nocov:
+
+ # Filter a given a value against a list of allowed values
+ # If no value is given or value is not allowed return default one
+ #
+ # @param [Object] value
+ # @param [Enumerable] allowed_values
+ # @param [Object] default
+ def filter_attribute(value, allowed_values, default: nil)
+ return default unless value
+ return value if allowed_values.include?(value)
+
+ default
+ end
+ # :nocov:
+ end
+end
diff --git a/app/components/pajamas/toggle_component.html.haml b/app/components/pajamas/toggle_component.html.haml
new file mode 100644
index 00000000000..1716e8b69c3
--- /dev/null
+++ b/app/components/pajamas/toggle_component.html.haml
@@ -0,0 +1,16 @@
+%span{ class: @classes,
+ data: { name: @name,
+ id: @id,
+ is_checked: @is_checked.to_s,
+ disabled: @is_disabled.to_s,
+ is_loading: @is_loading.to_s,
+ label: @label,
+ help: @help,
+ label_position: @label_position,
+ **@data } }
+
+-# Leverage this block to render a rich help text. To render a plain text help text,
+-# prefer the `help` parameter.
+- if content.present?
+ .gl-text-secondary.gl-mt-1
+ = content
diff --git a/app/components/pajamas/toggle_component.rb b/app/components/pajamas/toggle_component.rb
new file mode 100644
index 00000000000..2d99f3d3b69
--- /dev/null
+++ b/app/components/pajamas/toggle_component.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+# Renders a GlToggle root element
+# To actually initialize the component, make sure to call the initToggle helper from ~/toggles.
+class Pajamas::ToggleComponent < Pajamas::Component
+ LABEL_POSITION_OPTIONS = [:top, :left, :hidden].freeze
+
+ # @param [String] classes
+ # @param [String] label
+ # @param [Symbol] label_position :top, :left or :hidden
+ # @param [String] id
+ # @param [String] name
+ # @param [String] help
+ # @param [Hash] data
+ # @param [Boolean] is_disabled
+ # @param [Boolean] is_checked
+ # @param [Boolean] is_loading
+ def initialize(
+ classes:, label: nil, label_position: nil,
+ id: nil, name: nil, help: nil, data: {},
+ is_disabled: false, is_checked: false, is_loading: false)
+
+ @id = id
+ @name = name
+ @classes = classes
+ @label = label
+ @label_position = filter_attribute(label_position, LABEL_POSITION_OPTIONS)
+ @help = help
+ @data = data
+ @is_disabled = is_disabled
+ @is_checked = is_checked
+ @is_loading = is_loading
+ end
+end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 1d0930ba73c..0dd85376050 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -66,12 +66,17 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
render html: Gitlab::Highlight.highlight('payload.json', usage_data_json, language: 'json')
end
- format.json { render json: Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values, cached: true).to_json }
+
+ format.json do
+ Gitlab::UsageDataCounters::ServiceUsageDataCounter.count(:download_payload_click)
+
+ render json: Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values, cached: true).to_json
+ end
end
end
def reset_registration_token
- @application_setting.reset_runners_registration_token!
+ ::Ci::Runners::ResetRegistrationTokenService.new(@application_setting, current_user).execute
flash[:notice] = _('New runners registration token has been generated!')
redirect_to admin_runners_path
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index 4660b0bfbb0..ef843a84e6c 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -65,6 +65,6 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
target_path
broadcast_type
dismissable
- ))
+ ), target_access_levels: []).reverse_merge!(target_access_levels: [])
end
end
diff --git a/app/controllers/admin/clusters_controller.rb b/app/controllers/admin/clusters_controller.rb
index 9a642e53d86..052c8821588 100644
--- a/app/controllers/admin/clusters_controller.rb
+++ b/app/controllers/admin/clusters_controller.rb
@@ -2,6 +2,7 @@
class Admin::ClustersController < Clusters::ClustersController
include EnforcesAdminAuthentication
+ before_action :ensure_feature_enabled!
layout 'admin'
diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb
index e750b5c5ad4..468a1077694 100644
--- a/app/controllers/admin/cohorts_controller.rb
+++ b/app/controllers/admin/cohorts_controller.rb
@@ -5,6 +5,8 @@ class Admin::CohortsController < Admin::ApplicationController
feature_category :devops_reports
+ urgency :low
+
def index
@cohorts = load_cohorts
track_cohorts_visit
diff --git a/app/controllers/admin/dev_ops_report_controller.rb b/app/controllers/admin/dev_ops_report_controller.rb
index a235af7c538..47e3337aed7 100644
--- a/app/controllers/admin/dev_ops_report_controller.rb
+++ b/app/controllers/admin/dev_ops_report_controller.rb
@@ -9,6 +9,8 @@ class Admin::DevOpsReportController < Admin::ApplicationController
feature_category :devops_reports
+ urgency :low
+
# rubocop: disable CodeReuse/ActiveRecord
def show
@metric = DevOpsReport::Metric.order(:created_at).last&.present
diff --git a/app/controllers/admin/instance_review_controller.rb b/app/controllers/admin/instance_review_controller.rb
index 1ce6e66c6de..cc801bce5b7 100644
--- a/app/controllers/admin/instance_review_controller.rb
+++ b/app/controllers/admin/instance_review_controller.rb
@@ -2,6 +2,8 @@
class Admin::InstanceReviewController < Admin::ApplicationController
feature_category :devops_reports
+ urgency :low
+
def index
redirect_to("#{Gitlab::SubscriptionPortal.subscriptions_instance_review_url}?#{instance_review_params}")
end
diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb
index ad0ee0b2cef..db9835e65ec 100644
--- a/app/controllers/admin/integrations_controller.rb
+++ b/app/controllers/admin/integrations_controller.rb
@@ -5,6 +5,10 @@ class Admin::IntegrationsController < Admin::ApplicationController
before_action :not_found, unless: -> { instance_level_integrations? }
+ before_action do
+ push_frontend_feature_flag(:integration_form_sections, default_enabled: :yaml)
+ end
+
feature_category :integrations
def overrides
diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb
index 598c536d652..a4055cbe990 100644
--- a/app/controllers/admin/runner_projects_controller.rb
+++ b/app/controllers/admin/runner_projects_controller.rb
@@ -8,7 +8,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
def create
@runner = Ci::Runner.find(params[:runner_project][:runner_id])
- if @runner.assign_to(@project, current_user)
+ if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute
redirect_to edit_admin_runner_url(@runner), notice: s_('Runners|Runner assigned to project.')
else
redirect_to edit_admin_runner_url(@runner), alert: 'Failed adding runner to project'
@@ -18,7 +18,8 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
def destroy
rp = Ci::RunnerProject.find(params[:id])
runner = rp.runner
- rp.destroy
+
+ ::Ci::Runners::UnassignRunnerService.new(rp, current_user).execute
redirect_to edit_admin_runner_url(runner), status: :found, notice: s_('Runners|Runner unassigned from project.')
end
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index f7f78ab3229..2744be0150c 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -23,7 +23,7 @@ class Admin::RunnersController < Admin::ApplicationController
end
def update
- if Ci::UpdateRunnerService.new(@runner).update(runner_params)
+ if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params)
respond_to do |format|
format.html { redirect_to edit_admin_runner_path(@runner) }
end
@@ -34,13 +34,13 @@ class Admin::RunnersController < Admin::ApplicationController
end
def destroy
- Ci::UnregisterRunnerService.new(@runner).execute
+ Ci::Runners::UnregisterRunnerService.new(@runner, current_user).execute
redirect_to admin_runners_path, status: :found
end
def resume
- if Ci::UpdateRunnerService.new(@runner).update(active: true)
+ if Ci::Runners::UpdateRunnerService.new(@runner).update(active: true)
redirect_to admin_runners_path, notice: _('Runner was successfully updated.')
else
redirect_to admin_runners_path, alert: _('Runner was not updated.')
@@ -48,7 +48,7 @@ class Admin::RunnersController < Admin::ApplicationController
end
def pause
- if Ci::UpdateRunnerService.new(@runner).update(active: false)
+ if Ci::Runners::UpdateRunnerService.new(@runner).update(active: false)
redirect_to admin_runners_path, notice: _('Runner was successfully updated.')
else
redirect_to admin_runners_path, alert: _('Runner was not updated.')
diff --git a/app/controllers/admin/usage_trends_controller.rb b/app/controllers/admin/usage_trends_controller.rb
index 0b315517594..2cede1aec05 100644
--- a/app/controllers/admin/usage_trends_controller.rb
+++ b/app/controllers/admin/usage_trends_controller.rb
@@ -7,6 +7,8 @@ class Admin::UsageTrendsController < Admin::ApplicationController
feature_category :devops_reports
+ urgency :low
+
def index
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index c1fa104ffda..f19333d5d57 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -2,6 +2,7 @@
class Admin::UsersController < Admin::ApplicationController
include RoutableActions
+ include SortingHelper
before_action :user, except: [:index, :new, :create]
before_action :check_impersonation_availability, only: :impersonate
@@ -18,7 +19,8 @@ class Admin::UsersController < Admin::ApplicationController
@users = User.filter_items(params[:filter]).order_name_asc
@users = @users.search(params[:search_query], with_private_emails: true) if params[:search_query].present?
@users = users_with_included_associations(@users)
- @users = @users.sort_by_attribute(@sort = params[:sort])
+ @sort = params[:sort].presence || sort_value_name
+ @users = @users.sort_by_attribute(@sort)
@users = @users.page(params[:page])
@users = @users.without_count if paginate_without_count?
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 8e758c669db..1d17e8aa085 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -111,6 +111,15 @@ class ApplicationController < ActionController::Base
render plain: e.message, status: :too_many_requests
end
+ content_security_policy do |p|
+ next if p.directives.blank?
+ next unless Gitlab::CurrentSettings.snowplow_enabled? && !Gitlab::CurrentSettings.snowplow_collector_hostname.blank?
+
+ default_connect_src = p.directives['connect-src'] || p.directives['default-src']
+ connect_src_values = Array.wrap(default_connect_src) | [Gitlab::CurrentSettings.snowplow_collector_hostname]
+ p.connect_src(*connect_src_values)
+ end
+
def redirect_back_or_default(default: root_path, options: {})
redirect_back(fallback_location: default, **options)
end
@@ -237,19 +246,19 @@ class ApplicationController < ActionController::Base
end
def git_not_found!
- render "errors/git_not_found.html", layout: "errors", status: :not_found
+ render template: "errors/git_not_found", formats: :html, layout: "errors", status: :not_found
end
def render_403
respond_to do |format|
- format.html { render "errors/access_denied", layout: "errors", status: :forbidden }
+ format.html { render template: "errors/access_denied", formats: :html, layout: "errors", status: :forbidden }
format.any { head :forbidden }
end
end
def render_404
respond_to do |format|
- format.html { render "errors/not_found", layout: "errors", status: :not_found }
+ format.html { render template: "errors/not_found", formats: :html, layout: "errors", status: :not_found }
# Prevent the Rails CSRF protector from thinking a missing .js file is a JavaScript file
format.js { render json: '', status: :not_found, content_type: 'application/json' }
format.any { head :not_found }
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index ee5caf63703..4bcd1be9f53 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -1,8 +1,10 @@
# frozen_string_literal: true
class AutocompleteController < ApplicationController
+ include SearchRateLimitable
+
skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches]
- before_action :check_email_search_rate_limit!, only: [:users]
+ before_action :check_search_rate_limit!, only: [:users, :projects]
feature_category :users, [:users, :user]
feature_category :projects, [:projects]
@@ -72,12 +74,6 @@ class AutocompleteController < ApplicationController
def target_branch_params
params.permit(:group_id, :project_id).select { |_, v| v.present? }
end
-
- def check_email_search_rate_limit!
- search_params = Gitlab::Search::Params.new(params)
-
- check_rate_limit!(:user_email_lookup, scope: [current_user]) if search_params.email_lookup?
- end
end
AutocompleteController.prepend_mod_with('AutocompleteController')
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index c12ceca9c3b..d9179129983 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -9,15 +9,25 @@ class Clusters::ClustersController < Clusters::BaseController
before_action :generate_gcp_authorize_url, only: [:new]
before_action :validate_gcp_token, only: [:new]
before_action :gcp_cluster, only: [:new]
- before_action :user_cluster, only: [:new]
+ before_action :user_cluster, only: [:new, :connect]
before_action :authorize_read_cluster!, only: [:show, :index]
- before_action :authorize_create_cluster!, only: [:new, :authorize_aws_role]
+ before_action :authorize_create_cluster!, only: [:new, :connect, :authorize_aws_role]
before_action :authorize_update_cluster!, only: [:update]
before_action :update_applications_status, only: [:cluster_status]
+ before_action :ensure_feature_enabled!, except: :index
helper_method :token_in_session
STATUS_POLLING_INTERVAL = 10_000
+ AWS_CSP_DOMAINS = %w[https://ec2.ap-east-1.amazonaws.com https://ec2.ap-northeast-1.amazonaws.com https://ec2.ap-northeast-2.amazonaws.com https://ec2.ap-northeast-3.amazonaws.com https://ec2.ap-south-1.amazonaws.com https://ec2.ap-southeast-1.amazonaws.com https://ec2.ap-southeast-2.amazonaws.com https://ec2.ca-central-1.amazonaws.com https://ec2.eu-central-1.amazonaws.com https://ec2.eu-north-1.amazonaws.com https://ec2.eu-west-1.amazonaws.com https://ec2.eu-west-2.amazonaws.com https://ec2.eu-west-3.amazonaws.com https://ec2.me-south-1.amazonaws.com https://ec2.sa-east-1.amazonaws.com https://ec2.us-east-1.amazonaws.com https://ec2.us-east-2.amazonaws.com https://ec2.us-west-1.amazonaws.com https://ec2.us-west-2.amazonaws.com https://ec2.af-south-1.amazonaws.com https://iam.amazonaws.com].freeze
+
+ content_security_policy do |p|
+ next if p.directives.blank?
+
+ default_connect_src = p.directives['connect-src'] || p.directives['default-src']
+ connect_src_values = Array.wrap(default_connect_src) | AWS_CSP_DOMAINS
+ p.connect_src(*connect_src_values)
+ end
def index
@clusters = cluster_list
@@ -142,7 +152,7 @@ class Clusters::ClustersController < Clusters::BaseController
validate_gcp_token
gcp_cluster
- render :new, locals: { active_tab: 'add' }
+ render :connect
end
end
@@ -163,7 +173,17 @@ class Clusters::ClustersController < Clusters::BaseController
private
+ def certificate_based_clusters_enabled?
+ Feature.enabled?(:certificate_based_clusters, clusterable, default_enabled: :yaml, type: :ops)
+ end
+
+ def ensure_feature_enabled!
+ render_404 unless certificate_based_clusters_enabled?
+ end
+
def cluster_list
+ return [] unless certificate_based_clusters_enabled?
+
finder = ClusterAncestorsFinder.new(clusterable.subject, current_user)
clusters = finder.execute
diff --git a/app/controllers/concerns/floc_opt_out.rb b/app/controllers/concerns/floc_opt_out.rb
index 3039af02bbb..50a52cecda9 100644
--- a/app/controllers/concerns/floc_opt_out.rb
+++ b/app/controllers/concerns/floc_opt_out.rb
@@ -4,7 +4,7 @@ module FlocOptOut
extend ActiveSupport::Concern
included do
- after_action :set_floc_opt_out_header, unless: :floc_enabled?
+ before_action :set_floc_opt_out_header, unless: :floc_enabled?
end
def floc_enabled?
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index eae087bca4d..ae90bd59d01 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -17,10 +17,6 @@ module IssuableActions
def show
respond_to do |format|
format.html do
- @show_crm_contacts = issuable.is_a?(Issue) && # rubocop:disable Gitlab/ModuleWithInstanceVariables
- can?(current_user, :read_crm_contact, issuable.project.group) &&
- CustomerRelations::Contact.exists_for_group?(issuable.project.group)
-
@issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables
render 'show'
end
@@ -43,18 +39,18 @@ module IssuableActions
if updated_issuable.is_a?(Spammable)
respond_to do |format|
format.html do
- # NOTE: This redirect is intentionally only performed in the case where the updated
- # issuable is a spammable, and intentionally is not performed in the non-spammable case.
- # This preserves the legacy behavior of this action.
if updated_issuable.valid?
+ # NOTE: This redirect is intentionally only performed in the case where the valid updated
+ # issuable is a spammable, and intentionally is not performed below in the
+ # valid non-spammable case. This preserves the legacy behavior of this action.
redirect_to spammable_path
else
- with_captcha_check_html_format { render :edit }
+ with_captcha_check_html_format(spammable: spammable) { render :edit }
end
end
format.json do
- with_captcha_check_json_format { render_entity_json }
+ with_captcha_check_json_format(spammable: spammable) { render_entity_json }
end
end
else
diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index b68db0e3f9f..96cf6021ea9 100644
--- a/app/controllers/concerns/issuable_collections_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -17,7 +17,7 @@ module IssuableCollectionsAction
respond_to do |format|
format.html
- format.atom { render layout: 'xml.atom' }
+ format.atom { render layout: 'xml' }
end
end
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index f716c1f6c2f..0b9024dc3db 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -4,17 +4,6 @@ module MembershipActions
include MembersPresentation
extend ActiveSupport::Concern
- def create
- create_params = params.permit(:user_ids, :access_level, :expires_at)
- result = Members::CreateService.new(current_user, create_params.merge({ source: membershipable, invite_source: "#{plain_source_type}-members-page" })).execute
-
- if result[:status] == :success
- redirect_to members_page_url, notice: _('Users were successfully added.')
- else
- redirect_to members_page_url, alert: result[:message]
- end
- end
-
def update
update_params = params.require(root_params_key).permit(:access_level, :expires_at)
member = membershipable.members_and_requesters.find(params[:id])
@@ -79,8 +68,8 @@ module MembershipActions
notice: _('Your request for access has been queued for review.')
else
redirect_to polymorphic_path(membershipable),
- alert: _("Your request for access could not be processed: %{error_meesage}") %
- { error_meesage: access_requester.errors.full_messages.to_sentence }
+ alert: _("Your request for access could not be processed: %{error_message}") %
+ { error_message: access_requester.errors.full_messages.to_sentence }
end
end
diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb
new file mode 100644
index 00000000000..03296d6b233
--- /dev/null
+++ b/app/controllers/concerns/product_analytics_tracking.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module ProductAnalyticsTracking
+ include Gitlab::Tracking::Helpers
+ include RedisTracking
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def track_event(*controller_actions, name:, conditions: nil, destinations: [:redis_hll], &block)
+ custom_conditions = [:trackable_html_request?, *conditions]
+
+ after_action only: controller_actions, if: custom_conditions do
+ route_events_to(destinations, name, &block)
+ end
+ end
+ end
+
+ private
+
+ def route_events_to(destinations, name, &block)
+ track_unique_redis_hll_event(name, &block) if destinations.include?(:redis_hll)
+
+ if destinations.include?(:snowplow) && Feature.enabled?(:route_hll_to_snowplow, tracking_namespace_source, default_enabled: :yaml)
+ Gitlab::Tracking.event(self.class.to_s, name, namespace: tracking_namespace_source, user: current_user)
+ end
+ end
+end
diff --git a/app/controllers/concerns/search_rate_limitable.rb b/app/controllers/concerns/search_rate_limitable.rb
new file mode 100644
index 00000000000..a77ebd276b6
--- /dev/null
+++ b/app/controllers/concerns/search_rate_limitable.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module SearchRateLimitable
+ extend ActiveSupport::Concern
+
+ private
+
+ def check_search_rate_limit!
+ if current_user
+ check_rate_limit!(:search_rate_limit, scope: [current_user])
+ else
+ check_rate_limit!(:search_rate_limit_unauthenticated, scope: [request.ip])
+ end
+ end
+end
diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb
index 1f17f9f4e1b..48daacc09c2 100644
--- a/app/controllers/concerns/sessionless_authentication.rb
+++ b/app/controllers/concerns/sessionless_authentication.rb
@@ -26,6 +26,9 @@ module SessionlessAuthentication
# for every request. If you want the token to work as a
# sign in token, you can simply remove store: false.
sign_in(user, store: false, message: :sessionless_sign_in)
+ elsif request_authenticator.can_sign_in_bot?(user)
+ # we suppress callbacks to avoid redirecting the bot
+ sign_in(user, store: false, message: :sessionless_sign_in, run_callbacks: false)
end
end
diff --git a/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb b/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb
index 234c591ffb7..044519004b2 100644
--- a/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb
+++ b/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb
@@ -2,7 +2,6 @@
module SpammableActions::AkismetMarkAsSpamAction
extend ActiveSupport::Concern
- include SpammableActions::Attributes
included do
before_action :authorize_submit_spammable!, only: :mark_as_spam
@@ -22,7 +21,15 @@ module SpammableActions::AkismetMarkAsSpamAction
access_denied! unless current_user.can_admin_all_resources?
end
+ def spammable
+ # The class extending this module should define the #spammable method to return
+ # the Spammable model instance via: `alias_method :spammable , <:model_name>`
+ raise NotImplementedError, "#{self.class} should implement #{__method__}"
+ end
+
def spammable_path
- raise NotImplementedError, "#{self.class} does not implement #{__method__}"
+ # The class extending this module should define the #spammable_path method to return
+ # the route helper pointing to the action to show the Spammable instance
+ raise NotImplementedError, "#{self.class} should implement #{__method__}"
end
end
diff --git a/app/controllers/concerns/spammable_actions/attributes.rb b/app/controllers/concerns/spammable_actions/attributes.rb
deleted file mode 100644
index d7060e47c07..00000000000
--- a/app/controllers/concerns/spammable_actions/attributes.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module SpammableActions
- module Attributes
- extend ActiveSupport::Concern
-
- private
-
- def spammable
- raise NotImplementedError, "#{self.class} does not implement #{__method__}"
- end
- end
-end
diff --git a/app/controllers/concerns/spammable_actions/captcha_check/common.rb b/app/controllers/concerns/spammable_actions/captcha_check/common.rb
index 7c047e02a1d..aaeb6b3ba83 100644
--- a/app/controllers/concerns/spammable_actions/captcha_check/common.rb
+++ b/app/controllers/concerns/spammable_actions/captcha_check/common.rb
@@ -1,23 +1,25 @@
# frozen_string_literal: true
-module SpammableActions::CaptchaCheck
- module Common
- extend ActiveSupport::Concern
+module SpammableActions
+ module CaptchaCheck
+ module Common
+ extend ActiveSupport::Concern
- private
+ private
- def with_captcha_check_common(captcha_render_lambda:, &block)
- # If the Spammable indicates that CAPTCHA is not necessary (either due to it not being flagged
- # as spam, or if spam/captcha is disabled for some reason), then we will go ahead and
- # yield to the block containing the action's original behavior, then return.
- return yield unless spammable.render_recaptcha?
+ def with_captcha_check_common(spammable:, captcha_render_lambda:, &block)
+ # If the Spammable indicates that CAPTCHA is not necessary (either due to it not being flagged
+ # as spam, or if spam/captcha is disabled for some reason), then we will go ahead and
+ # yield to the block containing the action's original behavior, then return.
+ return yield unless spammable.render_recaptcha?
- # If we got here, we need to render the CAPTCHA instead of yielding to action's original
- # behavior. We will present a CAPTCHA to be solved by executing the lambda which was passed
- # as the `captcha_render_lambda:` argument. This lambda contains either the HTML-specific or
- # JSON-specific behavior to cause the CAPTCHA modal to be rendered.
- Gitlab::Recaptcha.load_configurations!
- captcha_render_lambda.call
+ # If we got here, we need to render the CAPTCHA instead of yielding to action's original
+ # behavior. We will present a CAPTCHA to be solved by executing the lambda which was passed
+ # as the `captcha_render_lambda:` argument. This lambda contains either the HTML-specific or
+ # JSON-specific behavior to cause the CAPTCHA modal to be rendered.
+ Gitlab::Recaptcha.load_configurations!
+ captcha_render_lambda.call
+ end
end
end
end
diff --git a/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb b/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb
index f687c0fcf2d..b254916cdd6 100644
--- a/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb
+++ b/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb
@@ -8,7 +8,6 @@
# which supports JSON format should be used instead.
module SpammableActions::CaptchaCheck::HtmlFormatActionsSupport
extend ActiveSupport::Concern
- include SpammableActions::Attributes
include SpammableActions::CaptchaCheck::Common
included do
@@ -17,9 +16,9 @@ module SpammableActions::CaptchaCheck::HtmlFormatActionsSupport
private
- def with_captcha_check_html_format(&block)
+ def with_captcha_check_html_format(spammable:, &block)
captcha_render_lambda = -> { render :captcha_check }
- with_captcha_check_common(captcha_render_lambda: captcha_render_lambda, &block)
+ with_captcha_check_common(spammable: spammable, captcha_render_lambda: captcha_render_lambda, &block)
end
# Convert spam/CAPTCHA values from form field params to headers, because all spam-related services
diff --git a/app/controllers/concerns/spammable_actions/captcha_check/json_format_actions_support.rb b/app/controllers/concerns/spammable_actions/captcha_check/json_format_actions_support.rb
index 0bfea05abc7..4a278a7b233 100644
--- a/app/controllers/concerns/spammable_actions/captcha_check/json_format_actions_support.rb
+++ b/app/controllers/concerns/spammable_actions/captcha_check/json_format_actions_support.rb
@@ -4,22 +4,27 @@
# In other words, forms handled by actions which use a `respond_to` of `format.js` or `format.json`.
#
# For example, for all Javascript based form submissions and Vue components which use Apollo and Axios
+# which are directly handled by a controller other than `GraphqlController`. For example, issue
+# update currently uses this module.
+#
+# However, requests which directly hit `GraphqlController` will not use this module - the
+# `Mutations::SpamProtection` module handles those requests (for example, snippet create/update
+# requests)
#
# If the request is handled by actions via `format.html`, then the corresponding module which
# supports HTML format should be used instead.
module SpammableActions::CaptchaCheck::JsonFormatActionsSupport
extend ActiveSupport::Concern
- include SpammableActions::Attributes
include SpammableActions::CaptchaCheck::Common
include Spam::Concerns::HasSpamActionResponseFields
private
- def with_captcha_check_json_format(&block)
+ def with_captcha_check_json_format(spammable:, &block)
# NOTE: "409 - Conflict" seems to be the most appropriate HTTP status code for a response
# which requires a CAPTCHA to be solved in order for the request to be resubmitted.
# https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.10
captcha_render_lambda = -> { render json: spam_action_response_fields(spammable), status: :conflict }
- with_captcha_check_common(captcha_render_lambda: captcha_render_lambda, &block)
+ with_captcha_check_common(spammable: spammable, captcha_render_lambda: captcha_render_lambda, &block)
end
end
diff --git a/app/controllers/concerns/spammable_actions/captcha_check/rest_api_actions_support.rb b/app/controllers/concerns/spammable_actions/captcha_check/rest_api_actions_support.rb
new file mode 100644
index 00000000000..2ebfa90e6da
--- /dev/null
+++ b/app/controllers/concerns/spammable_actions/captcha_check/rest_api_actions_support.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# This module should be included to support CAPTCHA check for REST API actions via Grape.
+#
+# If the request is directly handled by a controller action, then the corresponding module which
+# supports HTML or JSON formats should be used instead.
+module SpammableActions::CaptchaCheck::RestApiActionsSupport
+ extend ActiveSupport::Concern
+ include SpammableActions::CaptchaCheck::Common
+ include Spam::Concerns::HasSpamActionResponseFields
+
+ private
+
+ def with_captcha_check_rest_api(spammable:, &block)
+ # In the case of the REST API, the request is handled by Grape, so if there is a spam-related
+ # error, we don't render directly, instead we will pass the error message and other necessary
+ # fields to the Grape api error helper for it to handle.
+ captcha_render_lambda = -> do
+ fields = spam_action_response_fields(spammable)
+
+ fields.delete :spam
+ # NOTE: "409 - Conflict" seems to be the most appropriate HTTP status code for a response
+ # which requires a CAPTCHA to be solved in order for the request to be resubmitted.
+ # https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.10
+ status = 409
+
+ # NOTE: This nested 'error' key may not be consistent with all other API error responses,
+ # because they are not currently consistent across different API endpoints
+ # and models. Some (snippets) will nest errors in an errors key like this,
+ # while others (issues) will return the model's errors hash without an errors key,
+ # while still others just return a plain string error.
+ # See https://gitlab.com/groups/gitlab-org/-/epics/5527#revisit-inconsistent-shape-of-error-responses-in-rest-api
+ fields[:message] = { error: spammable.errors.full_messages.to_sentence }
+ render_structured_api_error!(fields, status)
+ end
+
+ with_captcha_check_common(spammable: spammable, captcha_render_lambda: captcha_render_lambda, &block)
+ end
+end
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index e1bfe92f61b..c9b6e8923fe 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -143,7 +143,7 @@ module UploadsActions
end
def bypass_auth_checks_on_uploads?
- if ::Feature.enabled?(:enforce_auth_checks_on_uploads, default_enabled: :yaml)
+ if ::Feature.enabled?(:enforce_auth_checks_on_uploads, project, default_enabled: :yaml)
false
else
action_name == 'show' && embeddable?
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 0074bcac360..4d6c7a63516 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -23,7 +23,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
format.atom do
load_events
- render layout: 'xml.atom'
+ render layout: 'xml'
end
format.json do
render json: {
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index f94da77609f..f25cc1bbc32 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -20,10 +20,6 @@ class DashboardController < Dashboard::ApplicationController
urgency :low, [:merge_requests]
- before_action only: [:merge_requests] do
- push_frontend_feature_flag(:mr_attention_requests, default_enabled: :yaml)
- end
-
def activity
respond_to do |format|
format.html
@@ -71,10 +67,15 @@ class DashboardController < Dashboard::ApplicationController
end
def check_filters_presence!
- no_scalar_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) }
- no_array_filters_set = finder_type.array_params.none? { |k, _| params.key?(k) }
+ no_scalar_filters_set = finder_type.scalar_params.none? { |k| params[k].present? }
+ no_array_filters_set = finder_type.array_params.none? { |k, _| params[k].present? }
+
+ # The `in` param is a modifier of `search`. If it's present while the `search`
+ # param isn't, the finder won't use the `in` param. We consider this as a no
+ # filter scenario.
+ no_search_filter_set = params[:in].present? && params[:search].blank?
- @no_filters_set = no_scalar_filters_set && no_array_filters_set
+ @no_filters_set = (no_scalar_filters_set && no_array_filters_set) || no_search_filter_set
return unless @no_filters_set
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index f9c875b80b2..bf72ade32d0 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -82,6 +82,10 @@ class Groups::ApplicationController < ApplicationController
def has_project_list?
false
end
+
+ def validate_root_group!
+ render_404 unless group.root?
+ end
end
Groups::ApplicationController.prepend_mod_with('Groups::ApplicationController')
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 6fac6fcf426..641b3adb12b 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(:issue_boards_filtered_search, group, default_enabled: :yaml)
push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb
index 666a96d6fc0..2fe9faa252f 100644
--- a/app/controllers/groups/clusters_controller.rb
+++ b/app/controllers/groups/clusters_controller.rb
@@ -3,6 +3,7 @@
class Groups::ClustersController < Clusters::ClustersController
include ControllerWithCrossProjectAccessCheck
+ before_action :ensure_feature_enabled!
prepend_before_action :group
requires_cross_project_access
diff --git a/app/controllers/groups/crm/contacts_controller.rb b/app/controllers/groups/crm/contacts_controller.rb
index f00f4d1df25..b59e20d9cea 100644
--- a/app/controllers/groups/crm/contacts_controller.rb
+++ b/app/controllers/groups/crm/contacts_controller.rb
@@ -3,6 +3,7 @@
class Groups::Crm::ContactsController < Groups::ApplicationController
feature_category :team_planning
+ before_action :validate_root_group!
before_action :authorize_read_crm_contact!
def new
diff --git a/app/controllers/groups/crm/organizations_controller.rb b/app/controllers/groups/crm/organizations_controller.rb
index ab720f490be..f8536b4f538 100644
--- a/app/controllers/groups/crm/organizations_controller.rb
+++ b/app/controllers/groups/crm/organizations_controller.rb
@@ -3,6 +3,7 @@
class Groups::Crm::OrganizationsController < Groups::ApplicationController
feature_category :team_planning
+ before_action :validate_root_group!
before_action :authorize_read_crm_organization!
def new
diff --git a/app/controllers/groups/deploy_tokens_controller.rb b/app/controllers/groups/deploy_tokens_controller.rb
index 79152bf2695..9ef22aa33dc 100644
--- a/app/controllers/groups/deploy_tokens_controller.rb
+++ b/app/controllers/groups/deploy_tokens_controller.rb
@@ -6,8 +6,7 @@ class Groups::DeployTokensController < Groups::ApplicationController
feature_category :continuous_delivery
def revoke
- @token = @group.deploy_tokens.find(params[:id])
- @token.revoke!
+ Groups::DeployTokens::RevokeService.new(@group, current_user, params).execute
redirect_to group_settings_repository_path(@group, anchor: 'js-deploy-tokens')
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 6e59f159636..ece1083d4d1 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -16,7 +16,7 @@ class Groups::GroupMembersController < Groups::ApplicationController
before_action :authorize_admin_group_member!, except: admin_not_required_endpoints
skip_before_action :check_two_factor_requirement, only: :leave
- skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
+ skip_cross_project_access_check :index, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite,
:override
@@ -26,8 +26,6 @@ class Groups::GroupMembersController < Groups::ApplicationController
@sort = params[:sort].presence || sort_value_name
if can?(current_user, :admin_group_member, @group)
- @skip_groups = @group.related_group_ids
-
@invited_members = invited_members
@invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present?
@invited_members = present_invited_members(@invited_members)
@@ -38,8 +36,6 @@ class Groups::GroupMembersController < Groups::ApplicationController
@requesters = present_members(
AccessRequestsFinder.new(@group).execute(current_user)
)
-
- @group_member = @group.group_members.new
end
# MembershipActions concern
diff --git a/app/controllers/groups/harbor/repositories_controller.rb b/app/controllers/groups/harbor/repositories_controller.rb
new file mode 100644
index 00000000000..364607f9b20
--- /dev/null
+++ b/app/controllers/groups/harbor/repositories_controller.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Groups
+ module Harbor
+ class RepositoriesController < Groups::ApplicationController
+ feature_category :integrations
+
+ before_action :harbor_registry_enabled!
+ before_action do
+ push_frontend_feature_flag(:harbor_registry_integration)
+ end
+
+ def show
+ render :index
+ end
+
+ private
+
+ def harbor_registry_enabled!
+ render_404 unless Feature.enabled?(:harbor_registry_integration)
+ end
+ end
+ end
+end
diff --git a/app/controllers/groups/releases_controller.rb b/app/controllers/groups/releases_controller.rb
index 6a42f30b847..db5385ecc71 100644
--- a/app/controllers/groups/releases_controller.rb
+++ b/app/controllers/groups/releases_controller.rb
@@ -15,11 +15,17 @@ module Groups
private
def releases
- ReleasesFinder
- .new(@group, current_user, { include_subgroups: true })
- .execute(preload: false)
- .page(params[:page])
- .per(30)
+ if Feature.enabled?(:group_releases_finder_inoperator)
+ Releases::GroupReleasesFinder
+ .new(@group, current_user, { include_subgroups: true, page: params[:page], per: 30 })
+ .execute(preload: false)
+ else
+ ReleasesFinder
+ .new(@group, current_user, { include_subgroups: true })
+ .execute(preload: false)
+ .page(params[:page])
+ .per(30)
+ end
end
end
end
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index b194aeff80d..dabef978ee1 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -24,7 +24,7 @@ class Groups::RunnersController < Groups::ApplicationController
end
def update
- if Ci::UpdateRunnerService.new(@runner).update(runner_params)
+ if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params)
redirect_to group_runner_path(@group, @runner), notice: _('Runner was successfully updated.')
else
render 'edit'
@@ -32,17 +32,17 @@ class Groups::RunnersController < Groups::ApplicationController
end
def destroy
- if @runner.belongs_to_more_than_one_project?
- redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found, alert: _('Runner was not deleted because it is assigned to multiple projects.')
- else
- Ci::UnregisterRunnerService.new(@runner).execute
+ if can?(current_user, :delete_runner, @runner)
+ Ci::Runners::UnregisterRunnerService.new(@runner, current_user).execute
redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found
+ else
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found, alert: _('Runner cannot be deleted, please contact your administrator.')
end
end
def resume
- if Ci::UpdateRunnerService.new(@runner).update(active: true)
+ if Ci::Runners::UpdateRunnerService.new(@runner).update(active: true)
redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: _('Runner was successfully updated.')
else
redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: _('Runner was not updated.')
@@ -50,7 +50,7 @@ class Groups::RunnersController < Groups::ApplicationController
end
def pause
- if Ci::UpdateRunnerService.new(@runner).update(active: false)
+ if Ci::Runners::UpdateRunnerService.new(@runner).update(active: false)
redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: _('Runner was successfully updated.')
else
redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: _('Runner was not updated.')
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index a290ef9b5e7..9b9e3f7b0bc 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -36,7 +36,7 @@ module Groups
end
def reset_registration_token
- @group.reset_runners_token!
+ ::Ci::Runners::ResetRegistrationTokenService.new(@group, current_user).execute
flash[:notice] = _('GroupSettings|New runners registration token has been generated!')
redirect_to group_settings_ci_cd_path
diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb
index 0a63c3d304b..ec64e75a68e 100644
--- a/app/controllers/groups/settings/integrations_controller.rb
+++ b/app/controllers/groups/settings/integrations_controller.rb
@@ -7,6 +7,10 @@ module Groups
before_action :authorize_admin_group!
+ before_action do
+ push_frontend_feature_flag(:integration_form_sections, group, default_enabled: :yaml)
+ end
+
feature_category :integrations
layout 'group_settings'
diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb
index 387f7be56cd..49249f87d31 100644
--- a/app/controllers/groups/uploads_controller.rb
+++ b/app/controllers/groups/uploads_controller.rb
@@ -4,7 +4,7 @@ class Groups::UploadsController < Groups::ApplicationController
include UploadsActions
include WorkhorseRequest
- skip_before_action :group, if: -> { bypass_auth_checks_on_uploads? }
+ skip_before_action :group, if: -> { action_name == 'show' && embeddable? }
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 12af76efe0d..b53d9b1be04 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -212,7 +212,7 @@ class GroupsController < Groups::ApplicationController
def issues
return super if !html_request? || Feature.disabled?(:vue_issues_list, group, default_enabled: :yaml)
- @has_issues = IssuesFinder.new(current_user, group_id: group.id).execute
+ @has_issues = IssuesFinder.new(current_user, group_id: group.id, include_subgroups: true).execute
.non_archived
.exists?
@@ -235,7 +235,7 @@ class GroupsController < Groups::ApplicationController
def render_details_view_atom
load_events
- render layout: 'xml.atom', template: 'groups/show'
+ render layout: 'xml', template: 'groups/show'
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/jira_connect/events_controller.rb b/app/controllers/jira_connect/events_controller.rb
index 1ea0a92662b..327192857f6 100644
--- a/app/controllers/jira_connect/events_controller.rb
+++ b/app/controllers/jira_connect/events_controller.rb
@@ -7,11 +7,13 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController
before_action :verify_asymmetric_atlassian_jwt!
def installed
- return head :ok if current_jira_installation
+ unless Feature.enabled?(:jira_connect_installation_update, default_enabled: :yaml)
+ return head :ok if current_jira_installation
+ end
- installation = JiraConnectInstallation.new(event_params)
+ success = current_jira_installation ? update_installation : create_installation
- if installation.save
+ if success
head :ok
else
head :unprocessable_entity
@@ -28,8 +30,24 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController
private
- def event_params
- params.permit(:clientKey, :sharedSecret, :baseUrl).transform_keys(&:underscore)
+ def create_installation
+ JiraConnectInstallation.new(create_params).save
+ end
+
+ def update_installation
+ current_jira_installation.update(update_params)
+ end
+
+ def create_params
+ transformed_params.permit(:client_key, :shared_secret, :base_url)
+ end
+
+ def update_params
+ transformed_params.permit(:shared_secret, :base_url)
+ end
+
+ def transformed_params
+ @transformed_params ||= params.transform_keys(&:underscore)
end
def verify_asymmetric_atlassian_jwt!
@@ -43,7 +61,7 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController
def jwt_verification_claims
{
aud: jira_connect_base_url(protocol: 'https'),
- iss: event_params[:client_key],
+ iss: transformed_params[:client_key],
qsh: Atlassian::Jwt.create_query_string_hash(request.url, request.method, jira_connect_base_url)
}
end
diff --git a/app/controllers/jira_connect/oauth_callbacks_controller.rb b/app/controllers/jira_connect/oauth_callbacks_controller.rb
new file mode 100644
index 00000000000..f603a563402
--- /dev/null
+++ b/app/controllers/jira_connect/oauth_callbacks_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# This controller's role is to serve as a landing page
+# that users get redirected to after installing and authenticating
+# The GitLab.com for Jira App (https://marketplace.atlassian.com/apps/1221011/gitlab-com-for-jira-cloud)
+#
+class JiraConnect::OauthCallbacksController < ApplicationController
+ feature_category :integrations
+
+ def index; end
+end
diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb
index fcd95c7942c..ec6ba07a125 100644
--- a/app/controllers/jira_connect/subscriptions_controller.rb
+++ b/app/controllers/jira_connect/subscriptions_controller.rb
@@ -16,6 +16,10 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
p.style_src(*style_src_values)
end
+ before_action do
+ push_frontend_feature_flag(:jira_connect_oauth, @user, default_enabled: :yaml)
+ end
+
before_action :allow_rendering_in_iframe, only: :index
before_action :verify_qsh_claim!, only: :index
before_action :authenticate_user!, only: :create
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 46738651960..d57a293ab4d 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -7,7 +7,7 @@ class ProfilesController < Profiles::ApplicationController
before_action :user
before_action :authorize_change_username!, only: :update_username
before_action only: :update_username do
- check_rate_limit!(:profile_update_username, scope: current_user) if Feature.enabled?(:rate_limit_profile_update_username, default_enabled: :yaml)
+ check_rate_limit!(:profile_update_username, scope: current_user)
end
skip_before_action :require_email, only: [:show, :update]
before_action do
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index 7a03e7b84b7..62233c8c3c9 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -24,11 +24,14 @@ class Projects::ApplicationController < ApplicationController
return unless params[:project_id] || params[:id]
path = File.join(params[:namespace_id], params[:project_id] || params[:id])
- auth_proc = ->(project) { !project.pending_delete? }
@project = find_routable!(Project, path, request.fullpath, extra_authorization_proc: auth_proc)
end
+ def auth_proc
+ ->(project) { !project.pending_delete? }
+ end
+
def build_canonical_path(project)
params[:namespace_id] = project.namespace.to_param
params[:project_id] = project.to_param
@@ -89,3 +92,5 @@ class Projects::ApplicationController < ApplicationController
return render_404 unless @project.feature_available?(:issues, current_user)
end
end
+
+Projects::ApplicationController.prepend_mod_with('Projects::ApplicationController')
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index b30ef7506aa..26a7b5662be 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -35,7 +35,6 @@ class Projects::BlobController < Projects::ApplicationController
before_action :editor_variables, except: [:show, :preview, :diff]
before_action :validate_diff_params, only: :diff
before_action :set_last_commit_sha, only: [:edit, :update]
- before_action :track_experiment, only: :create
track_redis_hll_event :create, :update, name: 'g_edit_by_sfe'
@@ -45,7 +44,6 @@ class Projects::BlobController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
@@ -55,7 +53,7 @@ class Projects::BlobController < Projects::ApplicationController
def create
create_commit(Files::CreateService, success_notice: _("The file has been successfully created."),
- success_path: -> { create_success_path },
+ success_path: -> { project_blob_path(@project, File.join(@branch_name, @file_path)) },
failure_view: :new,
failure_path: project_new_blob_path(@project, @ref))
end
@@ -283,20 +281,6 @@ class Projects::BlobController < Projects::ApplicationController
def visitor_id
current_user&.id
end
-
- def create_success_path
- if params[:code_quality_walkthrough]
- project_pipelines_path(@project, code_quality_walkthrough: true)
- else
- project_blob_path(@project, File.join(@branch_name, @file_path))
- end
- end
-
- def track_experiment
- return unless params[:code_quality_walkthrough]
-
- experiment(:code_quality_walkthrough, namespace: @project.root_ancestor).track(:commit_created)
- end
end
Projects::BlobController.prepend_mod
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 0170cff6160..c44a0830e2e 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -7,7 +7,6 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :check_issues_available!
before_action :assign_endpoint_vars
before_action do
- push_frontend_feature_flag(:issue_boards_filtered_search, project&.group, 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)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb
index c5f6ed1c105..61e8e5b015a 100644
--- a/app/controllers/projects/builds_controller.rb
+++ b/app/controllers/projects/builds_controller.rb
@@ -5,6 +5,9 @@ class Projects::BuildsController < Projects::ApplicationController
feature_category :continuous_integration
+ urgency :high, [:index, :show]
+ urgency :low, [:raw]
+
def index
redirect_to project_jobs_path(project)
end
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index 6f12e3940dd..8c6e8f0e126 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -2,7 +2,6 @@
class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
- before_action :setup_walkthrough_experiment, only: :show
before_action do
push_frontend_feature_flag(:schema_linting, @project, default_enabled: :yaml)
end
@@ -19,11 +18,4 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
def check_can_collaborate!
render_404 unless can_collaborate_with_project?(@project)
end
-
- def setup_walkthrough_experiment
- experiment(:pipeline_editor_walkthrough, namespace: @project.namespace, sticky_to: current_user) do |e|
- e.candidate {}
- e.publish_to_database
- end
- end
end
diff --git a/app/controllers/projects/ci/secure_files_controller.rb b/app/controllers/projects/ci/secure_files_controller.rb
new file mode 100644
index 00000000000..5141d0188b0
--- /dev/null
+++ b/app/controllers/projects/ci/secure_files_controller.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class Projects::Ci::SecureFilesController < Projects::ApplicationController
+ before_action :authorize_read_secure_files!
+
+ feature_category :pipeline_authoring
+
+ def show
+ end
+end
diff --git a/app/controllers/projects/cluster_agents_controller.rb b/app/controllers/projects/cluster_agents_controller.rb
index 84bb01ee266..282b9ef1fb7 100644
--- a/app/controllers/projects/cluster_agents_controller.rb
+++ b/app/controllers/projects/cluster_agents_controller.rb
@@ -3,10 +3,6 @@
class Projects::ClusterAgentsController < Projects::ApplicationController
before_action :authorize_can_read_cluster_agent!
- before_action do
- push_frontend_feature_flag(:cluster_vulnerabilities, project, default_enabled: :yaml)
- end
-
feature_category :kubernetes_management
def show
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 0ce0b8b8895..0c26b402876 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -164,6 +164,7 @@ class Projects::CommitController < Projects::ApplicationController
opts = diff_options
opts[:ignore_whitespace_change] = true if params[:format] == 'diff'
+ opts[:use_extra_viewer_as_main] = false
@diffs = commit.diffs(opts)
@notes_count = commit.notes.count
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 82a13b60b13..60b8e45f5be 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -30,7 +30,7 @@ class Projects::CommitsController < Projects::ApplicationController
respond_to do |format|
format.html
- format.atom { render layout: 'xml.atom' }
+ format.atom { render layout: 'xml' }
format.json do
pager_json(
diff --git a/app/controllers/projects/deploy_tokens_controller.rb b/app/controllers/projects/deploy_tokens_controller.rb
index 3c890bbafdf..42c2d8b17f1 100644
--- a/app/controllers/projects/deploy_tokens_controller.rb
+++ b/app/controllers/projects/deploy_tokens_controller.rb
@@ -12,3 +12,5 @@ class Projects::DeployTokensController < Projects::ApplicationController
redirect_to project_settings_repository_path(project, anchor: 'js-deploy-tokens')
end
end
+
+Projects::DeployTokensController.prepend_mod
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 84ebdcd9364..eabc048e341 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -29,13 +29,14 @@ class Projects::EnvironmentsController < Projects::ApplicationController
feature_category :continuous_delivery
def index
- @environments = project.environments
- .with_state(params[:scope] || :available)
@project = ProjectPresenter.new(project, current_user: current_user)
respond_to do |format|
format.html
format.json do
+ @environments = project.environments
+ .with_state(params[:scope] || :available)
+
Gitlab::PollingInterval.set_header(response, interval: 3_000)
environments_count_by_state = project.environments.count_by_state
@@ -52,14 +53,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController
# Returns all environments for a given folder
# rubocop: disable CodeReuse/ActiveRecord
def folder
- folder_environments = project.environments.where(environment_type: params[:id])
- @environments = folder_environments.with_state(params[:scope] || :available)
- .order(:name)
@folder = params[:id]
respond_to do |format|
format.html
format.json do
+ folder_environments = project.environments.where(environment_type: params[:id])
+ @environments = folder_environments.with_state(params[:scope] || :available)
+ .order(:name)
+
render json: {
environments: serialize_environments(request, response),
available_count: folder_environments.available.count,
diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb
index 8700d3c2198..06383d26133 100644
--- a/app/controllers/projects/error_tracking_controller.rb
+++ b/app/controllers/projects/error_tracking_controller.rb
@@ -6,6 +6,10 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle
before_action :authorize_read_sentry_issue!
before_action :set_issue_id, only: :details
+ before_action only: [:index] do
+ push_frontend_feature_flag(:integrated_error_tracking, project)
+ end
+
def index
respond_to do |format|
format.html
@@ -75,7 +79,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle
end
def list_issues_params
- params.permit(:search_term, :sort, :cursor, :issue_status)
+ params.permit(:search_term, :sort, :cursor, :issue_status).merge(tracking_event: :error_tracking_view_list)
end
def issue_update_params
@@ -83,7 +87,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle
end
def issue_details_params
- params.permit(:issue_id)
+ params.permit(:issue_id).merge(tracking_event: :error_tracking_view_details)
end
def set_issue_id
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 475c41eec9c..3208a5076e7 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -17,10 +17,6 @@ class Projects::ForksController < Projects::ApplicationController
feature_category :source_code_management
urgency :low, [:index]
- before_action do
- push_frontend_feature_flag(:fork_project_form, @project, default_enabled: :yaml)
- end
-
def index
@sort = forks_params[:sort]
@@ -54,9 +50,7 @@ class Projects::ForksController < Projects::ApplicationController
format.json do
namespaces = load_namespaces_with_associations - [project.namespace]
- namespaces = [current_user.namespace] + namespaces if
- Feature.enabled?(:fork_project_form, project, default_enabled: :yaml) &&
- can_fork_to?(current_user.namespace)
+ namespaces = [current_user.namespace] + namespaces if can_fork_to?(current_user.namespace)
render json: {
namespaces: ForkNamespaceSerializer.new.represent(
diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb
index f4a773a62f6..f293ec752ab 100644
--- a/app/controllers/projects/google_cloud/base_controller.rb
+++ b/app/controllers/projects/google_cloud/base_controller.rb
@@ -10,18 +10,25 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
private
def admin_project_google_cloud!
- access_denied! unless can?(current_user, :admin_project_google_cloud, project)
+ unless can?(current_user, :admin_project_google_cloud, project)
+ track_event('admin_project_google_cloud!', 'access_denied', 'invalid_user')
+ access_denied!
+ end
end
def google_oauth2_enabled!
config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2')
if config.app_id.blank? || config.app_secret.blank?
+ track_event('google_oauth2_enabled!', 'access_denied', { reason: 'google_oauth2_not_configured', config: config })
access_denied! 'This GitLab instance not configured for Google Oauth2.'
end
end
def feature_flag_enabled!
- access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud, project)
+ unless Feature.enabled?(:incubation_5mp_google_cloud)
+ track_event('feature_flag_enabled!', 'access_denied', 'feature_flag_not_enabled')
+ access_denied!
+ end
end
def validate_gcp_token!
@@ -53,9 +60,21 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
end
- def handle_gcp_error(error, project)
- Gitlab::ErrorTracking.track_exception(error, project_id: project.id)
+ def handle_gcp_error(action, error)
+ track_event(action, 'gcp_error', error)
@js_data = { screen: 'gcp_error', error: error.to_s }.to_json
render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error'
end
+
+ def track_event(action, label, property)
+ options = { label: label, project: project, user: current_user }
+
+ if property.is_a?(String)
+ options[:property] = property
+ else
+ options[:extra] = property
+ end
+
+ Gitlab::Tracking.event('Projects::GoogleCloud', action, **options)
+ end
end
diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb
index 1941eb8a5f9..4867d344c5a 100644
--- a/app/controllers/projects/google_cloud/deployments_controller.rb
+++ b/app/controllers/projects/google_cloud/deployments_controller.rb
@@ -9,6 +9,7 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base
.new(project, current_user, params).execute
if enable_cloud_run_response[:status] == :error
+ track_event('deployments#cloud_run', 'enable_cloud_run_error', enable_cloud_run_response)
flash[:error] = enable_cloud_run_response[:message]
redirect_to project_google_cloud_index_path(project)
else
@@ -17,15 +18,17 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base
.new(project, current_user, params).execute
if generate_pipeline_response[:status] == :error
+ track_event('deployments#cloud_run', 'generate_pipeline_error', generate_pipeline_response)
flash[:error] = 'Failed to generate pipeline'
redirect_to project_google_cloud_index_path(project)
else
cloud_run_mr_params = cloud_run_mr_params(generate_pipeline_response[:branch_name])
+ track_event('deployments#cloud_run', 'cloud_run_success', cloud_run_mr_params)
redirect_to project_new_merge_request_path(project, merge_request: cloud_run_mr_params)
end
end
rescue Google::Apis::ClientError => error
- handle_gcp_error(error, project)
+ handle_gcp_error('deployments#cloud_run', error)
end
def cloud_storage
diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
new file mode 100644
index 00000000000..beeb91cfd80
--- /dev/null
+++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseController
+ # filtered list of GCP cloud run locations...
+ # ...that have domain mapping available
+ # Source https://cloud.google.com/run/docs/locations 2022-01-30
+ AVAILABLE_REGIONS = %w[asia-east1 asia-northeast1 asia-southeast1 europe-north1 europe-west1 europe-west4 us-central1 us-east1 us-east4 us-west1].freeze
+
+ def index
+ @google_cloud_path = project_google_cloud_index_path(project)
+ params = { per_page: 50 }
+ branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true)
+ tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true)
+ refs = (branches + tags).map(&:name)
+ js_data = {
+ screen: 'gcp_regions_form',
+ availableRegions: AVAILABLE_REGIONS,
+ refs: refs,
+ cancelPath: project_google_cloud_index_path(project)
+ }
+ @js_data = js_data.to_json
+ track_event('gcp_regions#index', 'form_render', js_data)
+ end
+
+ def create
+ permitted_params = params.permit(:ref, :gcp_region)
+ response = GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region])
+ track_event('gcp_regions#create', 'form_submit', response)
+ redirect_to project_google_cloud_index_path(project), notice: _('GCP region configured')
+ end
+end
diff --git a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb
new file mode 100644
index 00000000000..03d1474707b
--- /dev/null
+++ b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Projects::GoogleCloud::RevokeOauthController < Projects::GoogleCloud::BaseController
+ before_action :validate_gcp_token!
+
+ def create
+ google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
+ response = google_api_client.revoke_authorizations
+
+ if response.success?
+ status = 'success'
+ redirect_message = { notice: s_('GoogleCloud|Google OAuth2 token revocation requested') }
+ else
+ status = 'failed'
+ redirect_message = { alert: s_('GoogleCloud|Google OAuth2 token revocation request failed') }
+ end
+
+ session.delete(GoogleApi::CloudPlatform::Client.session_key_for_token)
+ track_event('revoke_oauth#create', 'create', status)
+
+ redirect_to project_google_cloud_index_path(project), redirect_message
+ end
+end
diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb
index b5f2b658235..5d8b2030d5c 100644
--- a/app/controllers/projects/google_cloud/service_accounts_controller.rb
+++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb
@@ -10,30 +10,41 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
if gcp_projects.empty?
@js_data = { screen: 'no_gcp_projects' }.to_json
+ track_event('service_accounts#index', 'form_error', 'no_gcp_projects')
render status: :unauthorized, template: 'projects/google_cloud/errors/no_gcp_projects'
else
- @js_data = {
+ params = { per_page: 50 }
+ branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true)
+ tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true)
+ refs = (branches + tags).map(&:name)
+ js_data = {
screen: 'service_accounts_form',
gcpProjects: gcp_projects,
- environments: project.environments,
+ refs: refs,
cancelPath: project_google_cloud_index_path(project)
- }.to_json
+ }
+ @js_data = js_data.to_json
+
+ track_event('service_accounts#index', 'form_success', js_data)
end
rescue Google::Apis::ClientError => error
- handle_gcp_error(error, project)
+ handle_gcp_error('service_accounts#index', error)
end
def create
+ permitted_params = params.permit(:gcp_project, :ref)
+
response = GoogleCloud::CreateServiceAccountsService.new(
project,
current_user,
google_oauth2_token: token_in_session,
- gcp_project_id: params[:gcp_project],
- environment_name: params[:environment]
+ gcp_project_id: permitted_params[:gcp_project],
+ environment_name: permitted_params[:ref]
).execute
+ track_event('service_accounts#create', 'form_submit', response)
redirect_to project_google_cloud_index_path(project), notice: response.message
rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error
- handle_gcp_error(error, project)
+ handle_gcp_error('service_accounts#create', error)
end
end
diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb
index 206a8c7e391..49bb4bec859 100644
--- a/app/controllers/projects/google_cloud_controller.rb
+++ b/app/controllers/projects/google_cloud_controller.rb
@@ -1,14 +1,34 @@
# frozen_string_literal: true
class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController
+ GCP_REGION_CI_VAR_KEY = 'GCP_REGION'
+
def index
- @js_data = {
+ js_data = {
screen: 'home',
serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project,
createServiceAccountUrl: project_google_cloud_service_accounts_path(project),
enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project),
enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project),
- emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg')
- }.to_json
+ emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'),
+ configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project),
+ gcpRegions: gcp_regions,
+ revokeOauthUrl: revoke_oauth_url
+ }
+ @js_data = js_data.to_json
+ track_event('google_cloud#index', 'index', js_data)
+ end
+
+ private
+
+ def gcp_regions
+ list = ::Ci::VariablesFinder.new(project, { key: GCP_REGION_CI_VAR_KEY }).execute
+ list.map { |variable| { gcp_region: variable.value, environment: variable.environment_scope } }
+ end
+
+ def revoke_oauth_url
+ google_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
+ .validate_token(expires_at_in_session)
+ google_token_valid ? project_google_cloud_revoke_oauth_index_path(project) : nil
end
end
diff --git a/app/controllers/projects/harbor/application_controller.rb b/app/controllers/projects/harbor/application_controller.rb
new file mode 100644
index 00000000000..e6e694783fa
--- /dev/null
+++ b/app/controllers/projects/harbor/application_controller.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Projects
+ module Harbor
+ class ApplicationController < Projects::ApplicationController
+ layout 'project'
+
+ before_action :harbor_registry_enabled!
+ before_action do
+ push_frontend_feature_flag(:harbor_registry_integration)
+ end
+
+ feature_category :integrations
+
+ private
+
+ def harbor_registry_enabled!
+ render_404 unless Feature.enabled?(:harbor_registry_integration)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/harbor/repositories_controller.rb b/app/controllers/projects/harbor/repositories_controller.rb
new file mode 100644
index 00000000000..dd3e3dc1978
--- /dev/null
+++ b/app/controllers/projects/harbor/repositories_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Projects
+ module Harbor
+ class RepositoriesController < ::Projects::Harbor::ApplicationController
+ def show
+ render :index
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 3395e75666e..293581a6744 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -6,6 +6,11 @@ class Projects::IncidentsController < Projects::ApplicationController
before_action :authorize_read_issue!
before_action :load_incident, only: [:show]
+ before_action do
+ push_frontend_feature_flag(:incident_escalations, @project)
+ push_frontend_feature_flag(:incident_timeline_event_tab, @project, default_enabled: :yaml)
+ push_licensed_feature(:incident_timeline_events) if @project.licensed_feature_available?(:incident_timeline_events)
+ end
feature_category :incident_management
@@ -43,3 +48,5 @@ class Projects::IncidentsController < Projects::ApplicationController
IssueSerializer.new(current_user: current_user, project: incident.project)
end
end
+
+Projects::IncidentsController.prepend_mod
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 1b98810b09b..d4474b9d5a3 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -36,11 +36,6 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :authorize_import_issues!, only: [:import_csv]
before_action :authorize_download_code!, only: [:related_branches]
- # Limit the amount of issues created per minute
- before_action -> { check_rate_limit!(:issues_create, scope: [@project, @current_user])},
- only: [:create],
- if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) }
-
before_action do
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml)
@@ -50,12 +45,10 @@ class Projects::IssuesController < Projects::ApplicationController
end
before_action only: :show do
- push_frontend_feature_flag(:real_time_issue_sidebar, project, default_enabled: :yaml)
push_frontend_feature_flag(:confidential_notes, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:issue_assignees_widget, project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_issue_discussions, project, default_enabled: :yaml)
- push_frontend_feature_flag(:fix_comment_scroll, project, default_enabled: :yaml)
- push_frontend_feature_flag(:work_items, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:work_items, project&.group, default_enabled: :yaml)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
@@ -79,13 +72,16 @@ class Projects::IssuesController < Projects::ApplicationController
attr_accessor :vulnerability_id
def index
- set_issuables_index if !html_request? || Feature.disabled?(:vue_issues_list, project&.group, default_enabled: :yaml)
-
- @issues = @issuables
+ if html_request? && Feature.enabled?(:vue_issues_list, project&.group, default_enabled: :yaml)
+ set_sort_order
+ else
+ set_issuables_index
+ @issues = @issuables
+ end
respond_to do |format|
format.html
- format.atom { render layout: 'xml.atom' }
+ format.atom { render layout: 'xml' }
format.json do
render json: {
html: view_to_html_string("projects/issues/_issues"),
@@ -112,6 +108,8 @@ class Projects::IssuesController < Projects::ApplicationController
@issue = @noteable = service.execute
+ @add_related_issue = add_related_issue
+
@merge_request_to_resolve_discussions_of = service.merge_request_to_resolve_discussions_of
if params[:discussion_to_resolve]
@@ -128,6 +126,7 @@ class Projects::IssuesController < Projects::ApplicationController
def create
create_params = issue_params.merge(
+ add_related_issue: add_related_issue,
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
discussion_to_resolve: params[:discussion_to_resolve]
)
@@ -150,7 +149,7 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_to project_issue_path(@project, @issue)
else
# NOTE: this CAPTCHA support method is indirectly included via IssuableActions
- with_captcha_check_html_format { render :new }
+ with_captcha_check_html_format(spammable: spammable) { render :new }
end
end
@@ -383,6 +382,11 @@ class Projects::IssuesController < Projects::ApplicationController
action_name == 'service_desk'
end
+ def add_related_issue
+ add_related_issue = project.issues.find_by_iid(params[:add_related_issue])
+ add_related_issue if Ability.allowed?(current_user, :read_issue, add_related_issue)
+ end
+
# Overridden in EE
def create_vulnerability_issue_feedback(issue); end
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index bfc2fe6432d..b0f032a01e5 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -4,6 +4,8 @@ class Projects::JobsController < Projects::ApplicationController
include SendFileUpload
include ContinueParams
+ urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :status, :erase, :raw]
+
before_action :find_job_as_build, except: [:index, :play, :show]
before_action :find_job_as_processable, only: [:play, :show]
before_action :authorize_read_build_trace!, only: [:trace, :raw]
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 9bc9c19157a..0dcc2bc3181 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -111,9 +111,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
allow_tree_conflicts: display_merge_conflicts_in_diff?
)
- if @merge_request.project.context_commits_enabled?
- options[:context_commits] = @merge_request.recent_context_commits
- end
+ options[:context_commits] = @merge_request.recent_context_commits
render json: DiffsSerializer.new(request).represent(diffs, options)
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 6445f920db5..60d7920f83e 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -30,14 +30,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
- before_action only: [:index, :show] do
- push_frontend_feature_flag(:mr_attention_requests, project, default_enabled: :yaml)
- end
before_action only: [:show] do
push_frontend_feature_flag(:file_identifier_hash)
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(:paginated_notes, project, default_enabled: :yaml)
push_frontend_feature_flag(:confidential_notes, project, default_enabled: :yaml)
@@ -45,8 +41,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml)
push_frontend_feature_flag(:refactor_mr_widgets_extensions, project, default_enabled: :yaml)
push_frontend_feature_flag(:rebase_without_ci_ui, project, default_enabled: :yaml)
- push_frontend_feature_flag(:rearrange_pipelines_table, project, default_enabled: :yaml)
push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
# Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml)
push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
@@ -87,7 +84,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
:ci_environments_status,
:destroy,
:rebase,
- :discussions
+ :discussions,
+ :pipelines
]
def index
@@ -95,7 +93,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
respond_to do |format|
format.html
- format.atom { render layout: 'xml.atom' }
+ format.atom { render layout: 'xml' }
format.json do
render json: {
html: view_to_html_string("projects/merge_requests/_merge_requests")
@@ -220,8 +218,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def context_commits
- return render_404 unless project.context_commits_enabled?
-
# Get commits from repository
# or from cache if already merged
commits = ContextCommitsFinder.new(project, @merge_request, { search: params[:search], limit: params[:limit], offset: params[:offset] }).execute
@@ -553,12 +549,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def endpoint_metadata_url(project, merge_request)
- params = request.query_parameters
- params[:view] = "inline"
-
- if Feature.enabled?(:default_merge_ref_for_diffs, project, default_enabled: :yaml)
- params = params.merge(diff_head: true)
- end
+ params = request.query_parameters.merge(view: 'inline', diff_head: true)
diffs_metadata_project_json_merge_request_path(project, merge_request, 'json', params)
end
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index ac94cc001dd..271c31b6429 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -10,6 +10,10 @@ 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(:pipeline_schedules_with_tags, @project, default_enabled: :yaml)
+ end
+
feature_category :continuous_integration
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/pipelines/stages_controller.rb b/app/controllers/projects/pipelines/stages_controller.rb
index ce08b49ce9f..0447bbf29e7 100644
--- a/app/controllers/projects/pipelines/stages_controller.rb
+++ b/app/controllers/projects/pipelines/stages_controller.rb
@@ -5,6 +5,10 @@ module Projects
class StagesController < Projects::Pipelines::ApplicationController
before_action :authorize_update_pipeline!
+ urgency :low, [
+ :play_manual
+ ]
+
def play_manual
::Ci::PlayManualStageService
.new(@project, current_user, pipeline: pipeline)
diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb
index 25ec7ab1335..602fc02686a 100644
--- a/app/controllers/projects/pipelines/tests_controller.rb
+++ b/app/controllers/projects/pipelines/tests_controller.rb
@@ -42,9 +42,9 @@ module Projects
end
def test_suite
- suite = builds.map do |build|
+ suite = builds.sum do |build|
build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
- end.sum
+ end
Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, project).load!
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 7f680bbf121..8279bb20769 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -4,6 +4,9 @@ class Projects::PipelinesController < Projects::ApplicationController
include ::Gitlab::Utils::StrongMemoize
include RedisTracking
+ urgency :default, [:status]
+ urgency :low, [:index, :new, :builds, :show, :failures, :create, :stage, :retry, :dag, :cancel]
+
before_action :disable_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts, :config_variables]
before_action :set_pipeline_path, only: [:show]
@@ -13,13 +16,6 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action :ensure_pipeline, only: [:show, :downloadable_artifacts]
- before_action do
- push_frontend_feature_flag(:rearrange_pipelines_table, project, default_enabled: :yaml)
- end
-
- before_action do
- push_frontend_feature_flag(:jobs_tab_vue, @project, 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? }
@@ -55,8 +51,7 @@ class Projects::PipelinesController < Projects::ApplicationController
respond_to do |format|
format.html do
- enable_code_quality_walkthrough_experiment
- enable_ci_runner_templates_experiment
+ enable_runners_availability_section_experiment
end
format.json do
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
@@ -166,14 +161,20 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def retry
- ::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker
+ # Check for access before execution to allow for async execution while still returning access results
+ access_response = ::Ci::RetryPipelineService.new(@project, current_user).check_access(pipeline)
+
+ if access_response.error?
+ response = { json: { errors: [access_response.message] }, status: access_response.http_status }
+ else
+ response = { json: {}, status: :no_content }
+ ::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker
+ end
respond_to do |format|
- format.html do
- redirect_back_or_default default: project_pipelines_path(project)
+ format.json do
+ render response
end
-
- format.json { head :no_content }
end
end
@@ -224,7 +225,7 @@ class Projects::PipelinesController < Projects::ApplicationController
PipelineSerializer
.new(project: @project, current_user: @current_user)
.with_pagination(request, response)
- .represent(@pipelines, disable_coverage: true, preload: true, code_quality_walkthrough: params[:code_quality_walkthrough].present?)
+ .represent(@pipelines, disable_coverage: true, preload: true)
end
def render_show
@@ -309,28 +310,13 @@ class Projects::PipelinesController < Projects::ApplicationController
params.permit(:scope, :username, :ref, :status, :source)
end
- def enable_code_quality_walkthrough_experiment
- experiment(:code_quality_walkthrough, namespace: project.root_ancestor) do |e|
- e.exclude! unless current_user
- e.exclude! unless can?(current_user, :create_pipeline, project)
- e.exclude! unless project.root_ancestor.recent?
- e.exclude! if @pipelines_count.to_i > 0
- e.exclude! if helpers.has_gitlab_ci?(project)
-
- e.control {}
- e.candidate {}
- e.publish_to_database
- end
- end
-
- def enable_ci_runner_templates_experiment
- experiment(:ci_runner_templates, namespace: project.root_ancestor) do |e|
- e.exclude! unless current_user
- e.exclude! unless can?(current_user, :create_pipeline, project)
- e.exclude! if @pipelines_count.to_i > 0
- e.exclude! if helpers.has_gitlab_ci?(project)
+ def enable_runners_availability_section_experiment
+ return unless current_user
+ return unless can?(current_user, :create_pipeline, project)
+ return if @pipelines_count.to_i > 0
+ return if helpers.has_gitlab_ci?(project)
- e.control {}
+ experiment(:runners_availability_section, namespace: project.root_ancestor) do |e|
e.candidate {}
e.publish_to_database
end
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index dc0614c6bdd..0279a65f262 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -13,8 +13,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController
def index
@sort = params[:sort].presence || sort_value_name
- @skip_groups = @project.related_group_ids
-
@group_links = @project.project_group_links
@group_links = @group_links.search(params[:search_groups]) if params[:search_groups].present?
@@ -24,25 +22,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController
end
@project_members = present_members(non_invited_members.page(params[:page]))
-
- @project_member = @project.project_members.new
- end
-
- def import
- @projects = Project.visible_to_user_and_access_level(current_user, Gitlab::Access::MAINTAINER).order_id_desc
- end
-
- def apply_import
- source_project = Project.find(params[:source_project_id])
-
- if can?(current_user, :admin_project_member, source_project)
- status = @project.team.import(source_project, current_user)
- notice = status ? "Successfully imported" : "Import failed"
- else
- return render_404
- end
-
- redirect_to(project_project_members_path(project), notice: notice)
end
# MembershipActions concern
diff --git a/app/controllers/projects/redirect_controller.rb b/app/controllers/projects/redirect_controller.rb
new file mode 100644
index 00000000000..6bcbe87ee42
--- /dev/null
+++ b/app/controllers/projects/redirect_controller.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# Projects::RedirectController is used to resolve the route projects/:id.
+# It's helpful for this to be in its own controller so that the
+# ProjectsController can assume that :namespace_id exists
+class Projects::RedirectController < ::ApplicationController
+ skip_before_action :authenticate_user!
+
+ feature_category :projects
+
+ def redirect_from_id
+ project = Project.find(params[:id])
+
+ if can?(current_user, :read_project, project)
+ redirect_to project
+ else
+ render_404
+ end
+ end
+end
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 7fba6cc5bf4..1a2baf96020 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -7,6 +7,7 @@ class Projects::ReleasesController < Projects::ApplicationController
before_action :authorize_read_release!
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new
+ before_action :validate_suffix_path, :fetch_latest_tag, only: :latest_permalink
before_action only: :index do
push_frontend_feature_flag(:releases_index_apollo_client, project, default_enabled: :yaml)
end
@@ -26,10 +27,24 @@ class Projects::ReleasesController < Projects::ApplicationController
redirect_to link.url
end
+ def latest_permalink
+ unless @latest_tag.present?
+ return render_404
+ end
+
+ query_parameters_except_order_by = request.query_parameters.except(:order_by)
+
+ redirect_url = project_release_url(@project, @latest_tag)
+ redirect_url += "/#{params[:suffix_path]}" if params[:suffix_path]
+ redirect_url += "?#{query_parameters_except_order_by.compact.to_param}" if query_parameters_except_order_by.present?
+
+ redirect_to redirect_url
+ end
+
private
- def releases
- ReleasesFinder.new(@project, current_user).execute
+ def releases(params = {})
+ ReleasesFinder.new(@project, current_user, params).execute
end
def authorize_update_release!
@@ -51,4 +66,18 @@ class Projects::ReleasesController < Projects::ApplicationController
def sanitized_tag_name
CGI.unescape(params[:tag])
end
+
+ # Default order_by is 'released_at', which is set in ReleasesFinder.
+ # Also if the passed order_by is invalid, we reject and default to 'released_at'.
+ def fetch_latest_tag
+ allowed_values = ['released_at']
+
+ params.reject! { |key, value| key.to_sym == :order_by && !allowed_values.any?(value) }
+
+ @latest_tag = releases(order_by: params[:order_by]).first&.tag
+ end
+
+ def validate_suffix_path
+ Gitlab::Utils.check_path_traversal!(params[:suffix_path]) if params[:suffix_path]
+ end
end
diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb
index 39db7618db0..b77ce070492 100644
--- a/app/controllers/projects/runner_projects_controller.rb
+++ b/app/controllers/projects/runner_projects_controller.rb
@@ -14,7 +14,7 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
path = project_runners_path(project)
- if @runner.assign_to(project, current_user)
+ if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute
redirect_to path, notice: s_('Runners|Runner assigned to project.')
else
assign_to_messages = @runner.errors.messages[:assign_to]
@@ -26,7 +26,8 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
def destroy
runner_project = project.runner_projects.find(params[:id])
- runner_project.destroy
+
+ ::Ci::Runners::UnassignRunnerService.new(runner_project, current_user).execute
redirect_to project_runners_path(project), status: :found, notice: s_('Runners|Runner unassigned from project.')
end
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 192a29730d9..0eda8e3352d 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -14,7 +14,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
def update
- if Ci::UpdateRunnerService.new(@runner).update(runner_params)
+ if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params)
redirect_to project_runner_path(@project, @runner), notice: _('Runner was successfully updated.')
else
render 'edit'
@@ -23,14 +23,14 @@ class Projects::RunnersController < Projects::ApplicationController
def destroy
if @runner.only_for?(project)
- Ci::UnregisterRunnerService.new(@runner).execute
+ Ci::Runners::UnregisterRunnerService.new(@runner, current_user).execute
end
redirect_to project_runners_path(@project), status: :found
end
def resume
- if Ci::UpdateRunnerService.new(@runner).update(active: true)
+ if Ci::Runners::UpdateRunnerService.new(@runner).update(active: true)
redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.')
else
redirect_to project_runners_path(@project), alert: _('Runner was not updated.')
@@ -38,7 +38,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
def pause
- if Ci::UpdateRunnerService.new(@runner).update(active: false)
+ if Ci::Runners::UpdateRunnerService.new(@runner).update(active: false)
redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.')
else
redirect_to project_runners_path(@project), alert: _('Runner was not updated.')
diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb
index 3fc379a135a..b6f77a6d515 100644
--- a/app/controllers/projects/serverless/functions_controller.rb
+++ b/app/controllers/projects/serverless/functions_controller.rb
@@ -3,6 +3,7 @@
module Projects
module Serverless
class FunctionsController < Projects::ApplicationController
+ before_action :ensure_feature_enabled!
before_action :authorize_read_cluster!
feature_category :not_owned
@@ -69,6 +70,10 @@ module Projects
def serialize_function(function)
Projects::Serverless::ServiceSerializer.new(current_user: @current_user, project: project).represent(function)
end
+
+ def ensure_feature_enabled!
+ render_404 unless Feature.enabled?(:deprecated_serverless, project, default_enabled: :yaml, type: :ops)
+ end
end
end
end
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 1321111faaf..105f8efde7b 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -13,6 +13,10 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :set_deprecation_notice_for_prometheus_integration, only: [:edit, :update]
before_action :redirect_deprecated_prometheus_integration, only: [:update]
+ before_action do
+ push_frontend_feature_flag(:integration_form_sections, project, default_enabled: :yaml)
+ end
+
respond_to :html
layout "project_settings"
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index dd2fb57f7ac..3f4d26bb6ec 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -64,7 +64,7 @@ module Projects
end
def reset_registration_token
- @project.reset_runners_token!
+ ::Ci::Runners::ResetRegistrationTokenService.new(@project, current_user).execute
flash[:toast] = _("New runners registration token has been generated!")
redirect_to namespace_project_settings_ci_cd_path
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index 56e201c592f..43c72b358db 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -7,6 +7,10 @@ module Projects
before_action :authorize_admin_operations!
before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token]
+ before_action do
+ push_frontend_feature_flag(:integrated_error_tracking, project)
+ end
+
respond_to :json, only: [:reset_alerting_token, :reset_pagerduty_token]
helper_method :error_tracking_setting
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index 6472d3c3454..eb3579551bd 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -42,7 +42,7 @@ class Projects::TagsController < Projects::ApplicationController
status = @tags_loading_error ? :service_unavailable : :ok
format.html { render status: status }
- format.atom { render layout: 'xml.atom', status: status }
+ format.atom { render layout: 'xml', status: status }
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 4f905a2d565..e447fc3f3fe 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -22,7 +22,6 @@ class Projects::TreeController < Projects::ApplicationController
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
- push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml)
end
feature_category :source_code_management
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 519d9cd0d52..507a8b66942 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -17,10 +17,10 @@ class ProjectsController < Projects::ApplicationController
around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
before_action :disable_query_limiting, only: [:show, :create]
- before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :resolve, :unfoldered_environment_names]
+ before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :unfoldered_environment_names]
before_action :redirect_git_extension, only: [:show]
- before_action :project, except: [:index, :new, :create, :resolve]
- before_action :repository, except: [:index, :new, :create, :resolve]
+ before_action :project, except: [:index, :new, :create]
+ before_action :repository, except: [:index, :new, :create]
before_action :verify_git_import_enabled, only: [:create]
before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export]
before_action :present_project, only: [:edit]
@@ -41,7 +41,6 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml)
push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
- push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml)
push_frontend_feature_flag(:work_items, @project, default_enabled: :yaml)
end
@@ -49,7 +48,7 @@ class ProjectsController < Projects::ApplicationController
feature_category :projects, [
:index, :show, :new, :create, :edit, :update, :transfer,
- :destroy, :resolve, :archive, :unarchive, :toggle_star, :activity
+ :destroy, :archive, :unarchive, :toggle_star, :activity
]
feature_category :source_code_management, [:remove_fork, :housekeeping, :refs]
@@ -174,7 +173,7 @@ class ProjectsController < Projects::ApplicationController
format.atom do
load_events
@events = @events.select { |event| event.visible_to_user?(current_user) }
- render layout: 'xml.atom'
+ render layout: 'xml'
end
end
end
@@ -325,16 +324,6 @@ class ProjectsController < Projects::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
- def resolve
- @project = Project.find(params[:id])
-
- if can?(current_user, :read_project, @project)
- redirect_to @project
- else
- render_404
- end
- end
-
def unfoldered_environment_names
respond_to do |format|
format.json do
@@ -346,11 +335,7 @@ class ProjectsController < Projects::ApplicationController
private
def refs_params
- if Feature.enabled?(:strong_parameters_for_project_controller, @project, default_enabled: :yaml)
- params.permit(:search, :sort, :ref, find: [])
- else
- params
- end
+ params.permit(:search, :sort, :ref, find: [])
end
# Render project landing depending of which features are available
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 057c451ace2..7011bf856e3 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -15,7 +15,7 @@ class RegistrationsController < Devise::RegistrationsController
before_action :load_recaptcha, only: :new
before_action :set_invite_params, only: :new
before_action only: [:create] do
- check_rate_limit!(:user_sign_up, scope: request.ip) if Feature.enabled?(:rate_limit_user_sign_up_endpoint, default_enabled: :yaml)
+ check_rate_limit!(:user_sign_up, scope: request.ip)
end
before_action only: [:new] do
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index e38eeaed367..817da658f14 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -4,6 +4,7 @@ class SearchController < ApplicationController
include ControllerWithCrossProjectAccessCheck
include SearchHelper
include RedisTracking
+ include SearchRateLimitable
RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete].freeze
@@ -17,7 +18,7 @@ class SearchController < ApplicationController
search_term_present = params[:search].present? || params[:term].present?
search_term_present && !params[:project_id].present?
end
- before_action :check_email_search_rate_limit!, only: [:show, :count, :autocomplete]
+ before_action :check_search_rate_limit!, only: [:show, :count, :autocomplete]
rescue_from ActiveRecord::QueryCanceled, with: :render_timeout
@@ -25,6 +26,7 @@ class SearchController < ApplicationController
feature_category :global_search
urgency :high, [:opensearch]
+ urgency :low, [:count]
def show
@project = search_service.project
@@ -201,12 +203,6 @@ class SearchController < ApplicationController
render status: :request_timeout
end
end
-
- def check_email_search_rate_limit!
- return unless search_service.params.email_lookup?
-
- check_rate_limit!(:user_email_lookup, scope: [current_user])
- end
end
SearchController.prepend_mod_with('SearchController')
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index d7eb3ccd274..4df0ef78907 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -40,30 +40,29 @@ class UploadsController < ApplicationController
upload_model_class.find(params[:id])
end
- def authorize_access!
- authorized =
- case model
- when Note
- can?(current_user, :read_project, model.project)
- when Snippet, ProjectSnippet
- can?(current_user, :read_snippet, model)
- when User
- # We validate the current user has enough (writing)
- # access to itself when a secret is given.
- # For instance, user avatars are readable by anyone,
- # while temporary, user snippet uploads are not.
- !secret? || can?(current_user, :update_user, model)
- when Appearance
- true
- when Projects::Topic
- true
- else
- permission = "read_#{model.class.underscore}".to_sym
-
- can?(current_user, permission, model)
- end
+ def authorized?
+ case model
+ when Note
+ can?(current_user, :read_project, model.project)
+ when Snippet, ProjectSnippet
+ can?(current_user, :read_snippet, model)
+ when User
+ # We validate the current user has enough (writing)
+ # access to itself when a secret is given.
+ # For instance, user avatars are readable by anyone,
+ # while temporary, user snippet uploads are not.
+ !secret? || can?(current_user, :update_user, model)
+ when Appearance
+ true
+ when Projects::Topic
+ true
+ else
+ can?(current_user, "read_#{model.class.underscore}".to_sym, model)
+ end
+ end
- render_unauthorized unless authorized
+ def authorize_access!
+ render_unauthorized unless authorized?
end
def authorize_create_access!
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index f6cef7e133c..dc02e4a3e87 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -24,7 +24,7 @@ class UsersController < ApplicationController
before_action :authorize_read_user_profile!,
only: [:calendar, :calendar_activities, :groups, :projects, :contributed, :starred, :snippets, :followers, :following]
before_action only: [:exists] do
- check_rate_limit!(:username_exists, scope: request.ip) if Feature.enabled?(:rate_limit_username_exists_endpoint, default_enabled: :yaml)
+ check_rate_limit!(:username_exists, scope: request.ip)
end
feature_category :users
@@ -35,7 +35,7 @@ class UsersController < ApplicationController
format.atom do
load_events
- render layout: 'xml.atom'
+ render layout: 'xml'
end
format.json do
diff --git a/app/events/repositories/keep_around_refs_created_event.rb b/app/events/repositories/keep_around_refs_created_event.rb
new file mode 100644
index 00000000000..2ac499e6e21
--- /dev/null
+++ b/app/events/repositories/keep_around_refs_created_event.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Repositories
+ class KeepAroundRefsCreatedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'project_id' => { 'type' => 'integer' }
+ }
+ }
+ end
+ end
+end
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index f6af7ca15bb..f74e7fe3b1d 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -1,19 +1,7 @@
# frozen_string_literal: true
class ApplicationExperiment < Gitlab::Experiment
- def publish(_result = nil)
- super
-
- publish_to_client
- end
-
- def publish_to_client
- return unless should_track?
-
- Gon.push({ experiment: { name => signature } }, true)
- rescue NoMethodError
- # means we're not in the request cycle, and can't add to Gon. Log a warning maybe?
- end
+ control { nil } # provide a default control for anonymous experiments
def publish_to_database
ActiveSupport::Deprecation.warn('publish_to_database is deprecated and should not be used for reporting anymore')
diff --git a/app/experiments/combined_registration_experiment.rb b/app/experiments/combined_registration_experiment.rb
index 576e10815aa..38295cec0d3 100644
--- a/app/experiments/combined_registration_experiment.rb
+++ b/app/experiments/combined_registration_experiment.rb
@@ -3,6 +3,9 @@
class CombinedRegistrationExperiment < ApplicationExperiment
include Rails.application.routes.url_helpers
+ control { new_users_sign_up_group_path }
+ candidate { new_users_sign_up_groups_project_path }
+
def key_for(source, _ = nil)
super(source, 'force_company_trial')
end
@@ -10,12 +13,4 @@ class CombinedRegistrationExperiment < ApplicationExperiment
def redirect_path
run
end
-
- def control_behavior
- new_users_sign_up_group_path
- end
-
- def candidate_behavior
- new_users_sign_up_groups_project_path
- end
end
diff --git a/app/experiments/in_product_guidance_environments_webide_experiment.rb b/app/experiments/in_product_guidance_environments_webide_experiment.rb
index 6567ec0b3f1..78602874cb7 100644
--- a/app/experiments/in_product_guidance_environments_webide_experiment.rb
+++ b/app/experiments/in_product_guidance_environments_webide_experiment.rb
@@ -1,11 +1,9 @@
# frozen_string_literal: true
class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment
- exclude :has_environments?
+ control { false }
- def control_behavior
- false
- end
+ exclude :has_environments?
private
diff --git a/app/experiments/new_project_sast_enabled_experiment.rb b/app/experiments/new_project_sast_enabled_experiment.rb
index ee9d0dc1700..4aca4c875b2 100644
--- a/app/experiments/new_project_sast_enabled_experiment.rb
+++ b/app/experiments/new_project_sast_enabled_experiment.rb
@@ -1,21 +1,15 @@
# frozen_string_literal: true
class NewProjectSastEnabledExperiment < ApplicationExperiment
- def publish(_result = nil)
+ control { }
+ variant(:candidate) { }
+ variant(:free_indicator) { }
+ variant(:unchecked_candidate) { }
+ variant(:unchecked_free_indicator) { }
+
+ def publish(*args)
super
publish_to_database
end
-
- def candidate_behavior
- end
-
- def free_indicator_behavior
- end
-
- def unchecked_candidate_behavior
- end
-
- def unchecked_free_indicator_behavior
- end
end
diff --git a/app/experiments/require_verification_for_namespace_creation_experiment.rb b/app/experiments/require_verification_for_namespace_creation_experiment.rb
index 0c47f5d183c..cb667c6ae60 100644
--- a/app/experiments/require_verification_for_namespace_creation_experiment.rb
+++ b/app/experiments/require_verification_for_namespace_creation_experiment.rb
@@ -1,18 +1,13 @@
# frozen_string_literal: true
class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment
+ control { false }
+ candidate { true }
+
exclude :existing_user
EXPERIMENT_START_DATE = Date.new(2022, 1, 31)
- def control_behavior
- false
- end
-
- def candidate_behavior
- true
- end
-
def candidate?
run
end
diff --git a/app/experiments/security_reports_mr_widget_prompt_experiment.rb b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
index bcb9d64fcb7..51b81be672d 100644
--- a/app/experiments/security_reports_mr_widget_prompt_experiment.rb
+++ b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
@@ -1,14 +1,12 @@
# frozen_string_literal: true
class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment
+ control { }
+ candidate { }
+
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/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb
index 53dbf65c43a..fc18bb1984a 100644
--- a/app/finders/admin/projects_finder.rb
+++ b/app/finders/admin/projects_finder.rb
@@ -69,7 +69,7 @@ class Admin::ProjectsFinder
end
def sort(items)
- sort = params.fetch(:sort) { 'latest_activity_desc' }
+ sort = params.fetch(:sort, 'latest_activity_desc')
items.sort_by_attribute(sort)
end
end
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index fff17098c7b..4213a3f1965 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -60,6 +60,8 @@ class GroupMembersFinder < UnionFinder
members = members.filter_by_2fa(params[:two_factor])
end
+ members = apply_additional_filters(members)
+
by_created_at(members)
end
@@ -84,6 +86,11 @@ class GroupMembersFinder < UnionFinder
raise ArgumentError, "#{(include_relations - RELATIONS).first} #{INVALID_RELATION_TYPE_ERROR_MSG}"
end
end
+
+ def apply_additional_filters(members)
+ # overridden in EE to include additional filtering conditions.
+ members
+ end
end
GroupMembersFinder.prepend_mod_with('GroupMembersFinder')
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 3e436f30971..bf7b2265ded 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -46,6 +46,7 @@ class IssuableFinder
requires_cross_project_access unless: -> { params.project? }
+ FULL_TEXT_SEARCH_TERM_REGEX = /\A[\p{ASCII}|\p{Latin}]+\z/.freeze
NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze
attr_accessor :current_user, :params
@@ -331,6 +332,8 @@ class IssuableFinder
return items if items.is_a?(ActiveRecord::NullRelation)
return items if Feature.enabled?(:disable_anonymous_search, type: :ops) && current_user.nil?
+ return items.pg_full_text_search(search) if use_full_text_search?
+
if use_cte_for_search?
cte = Gitlab::SQL::CTE.new(klass.table_name, items)
@@ -341,6 +344,13 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ def use_full_text_search?
+ params[:in].blank? &&
+ klass.try(:pg_full_text_searchable_columns).present? &&
+ params[:search] =~ FULL_TEXT_SEARCH_TERM_REGEX &&
+ Feature.enabled?(:issues_full_text_search, params.project || params.group, default_enabled: :yaml)
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_iids(items)
params[:iids].present? ? items.where(iid: params[:iids]) : items
diff --git a/app/finders/pending_todos_finder.rb b/app/finders/pending_todos_finder.rb
index 509370b49a8..babff65cc37 100644
--- a/app/finders/pending_todos_finder.rb
+++ b/app/finders/pending_todos_finder.rb
@@ -27,7 +27,8 @@ class PendingTodosFinder
todos = by_target_id(todos)
todos = by_target_type(todos)
todos = by_discussion(todos)
- by_commit_id(todos)
+ todos = by_commit_id(todos)
+ by_action(todos)
end
def by_project(todos)
@@ -69,4 +70,10 @@ class PendingTodosFinder
todos
end
end
+
+ def by_action(todos)
+ return todos if params[:action].blank?
+
+ todos.for_action(params[:action])
+ end
end
diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb
index 4a6eed8f5ee..be266045951 100644
--- a/app/finders/personal_access_tokens_finder.rb
+++ b/app/finders/personal_access_tokens_finder.rb
@@ -17,6 +17,7 @@ class PersonalAccessTokensFinder
tokens = by_users(tokens)
tokens = by_impersonation(tokens)
tokens = by_state(tokens)
+ tokens = by_owner_type(tokens)
sort(tokens)
end
@@ -32,6 +33,15 @@ class PersonalAccessTokensFinder
tokens
end
+ def by_owner_type(tokens)
+ case @params[:owner_type]
+ when 'human'
+ tokens.owner_is_human
+ else
+ tokens
+ end
+ end
+
def by_user(tokens)
return tokens unless @params[:user]
diff --git a/app/finders/projects/members/effective_access_level_finder.rb b/app/finders/projects/members/effective_access_level_finder.rb
index 4538fc4c855..90474aba02c 100644
--- a/app/finders/projects/members/effective_access_level_finder.rb
+++ b/app/finders/projects/members/effective_access_level_finder.rb
@@ -40,7 +40,7 @@ module Projects
avenues = [authorizable_project_members]
avenues << if project.personal?
- project_owner_acting_as_maintainer
+ project_owner
else
authorizable_group_members
end
@@ -85,9 +85,11 @@ module Projects
Member.from_union(members)
end
- def project_owner_acting_as_maintainer
+ # workaround until we migrate Project#owners to have membership with
+ # OWNER access level
+ def project_owner
user_id = project.namespace.owner.id
- access_level = Gitlab::Access::MAINTAINER
+ access_level = Gitlab::Access::OWNER
Member
.from(generate_from_statement([[user_id, access_level]])) # rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/finders/projects/topics_finder.rb b/app/finders/projects/topics_finder.rb
index 7c3abc27cf7..c26b166a786 100644
--- a/app/finders/projects/topics_finder.rb
+++ b/app/finders/projects/topics_finder.rb
@@ -12,7 +12,7 @@ module Projects
end
def execute
- topics = Projects::Topic.order_by_total_projects_count
+ topics = Projects::Topic.order_by_non_private_projects_count
by_search(topics)
end
diff --git a/app/finders/releases/group_releases_finder.rb b/app/finders/releases/group_releases_finder.rb
new file mode 100644
index 00000000000..d87ba8c0b03
--- /dev/null
+++ b/app/finders/releases/group_releases_finder.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+module Releases
+ ##
+ # The GroupReleasesFinder does not support all the options of ReleasesFinder
+ # due to use of InOperatorOptimization for finding subprojects/subgroups
+ #
+ # order_by - only ordering by released_at is supported
+ # filter by tag - currently not supported
+ class GroupReleasesFinder
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :parent, :current_user, :params
+
+ def initialize(parent, current_user = nil, params = {})
+ @parent = parent
+ @current_user = current_user
+ @params = params
+
+ params[:order_by] ||= 'released_at'
+ params[:sort] ||= 'desc'
+ params[:page] ||= 0
+ params[:per] ||= 30
+ end
+
+ def execute(preload: true)
+ return Release.none unless Ability.allowed?(current_user, :read_release, parent)
+
+ releases = get_releases(preload: preload)
+
+ paginate_releases(releases)
+ end
+
+ private
+
+ def include_subgroups?
+ params.fetch(:include_subgroups, false)
+ end
+
+ def accessible_projects_scope
+ if include_subgroups?
+ Project.for_group_and_its_subgroups(parent)
+ else
+ parent.projects
+ end
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def get_releases(preload: true)
+ Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new(
+ scope: releases_scope(preload: preload),
+ array_scope: accessible_projects_scope.select(:id),
+ array_mapping_scope: -> (project_id_expression) { Release.where(Release.arel_table[:project_id].eq(project_id_expression)) },
+ finder_query: -> (order_by, id_expression) { Release.where(Release.arel_table[:id].eq(id_expression)) }
+ )
+ .execute
+ end
+
+ def releases_scope(preload: true)
+ scope = Release.all
+ scope = order_releases(scope)
+ scope = scope.preloaded if preload
+ scope
+ end
+
+ def order_releases(scope)
+ scope.sort_by_attribute("released_at_#{params[:sort]}").order(id: params[:sort])
+ end
+
+ def paginate_releases(releases)
+ releases.page(params[:page].to_i).per(params[:per])
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/graphql/mutations/ci/pipeline/retry.rb b/app/graphql/mutations/ci/pipeline/retry.rb
index ee93f99703e..895397a96ab 100644
--- a/app/graphql/mutations/ci/pipeline/retry.rb
+++ b/app/graphql/mutations/ci/pipeline/retry.rb
@@ -17,10 +17,11 @@ module Mutations
pipeline = authorized_find!(id: id)
project = pipeline.project
- ::Ci::RetryPipelineService.new(project, current_user).execute(pipeline)
+ service_response = ::Ci::RetryPipelineService.new(project, current_user).execute(pipeline)
+
{
pipeline: pipeline,
- errors: errors_on_object(pipeline)
+ errors: errors_on_object(pipeline) + service_response.errors
}
end
end
diff --git a/app/graphql/mutations/ci/runner/delete.rb b/app/graphql/mutations/ci/runner/delete.rb
index 21c3d55881c..1713ec0bf6d 100644
--- a/app/graphql/mutations/ci/runner/delete.rb
+++ b/app/graphql/mutations/ci/runner/delete.rb
@@ -17,20 +17,11 @@ module Mutations
def resolve(id:, **runner_attrs)
runner = authorized_find!(id)
- error = authenticate_delete_runner!(runner)
- return { errors: [error] } if error
-
- ::Ci::UnregisterRunnerService.new(runner).execute
+ ::Ci::Runners::UnregisterRunnerService.new(runner, current_user).execute
{ errors: runner.errors.full_messages }
end
- def authenticate_delete_runner!(runner)
- return if current_user.can_admin_all_resources?
-
- "Runner #{runner.to_global_id} associated with more than one project" if runner.runner_projects.count > 1
- end
-
def find_object(id)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb
index e6123b4283a..3432840f60f 100644
--- a/app/graphql/mutations/ci/runner/update.rb
+++ b/app/graphql/mutations/ci/runner/update.rb
@@ -53,7 +53,7 @@ module Mutations
def resolve(id:, **runner_attrs)
runner = authorized_find!(id)
- unless ::Ci::UpdateRunnerService.new(runner).update(runner_attrs)
+ unless ::Ci::Runners::UpdateRunnerService.new(runner).update(runner_attrs)
return { runner: nil, errors: runner.errors.full_messages }
end
diff --git a/app/graphql/mutations/ci/runners_registration_token/reset.rb b/app/graphql/mutations/ci/runners_registration_token/reset.rb
index 7976e8fb70d..29ef7aa2e81 100644
--- a/app/graphql/mutations/ci/runners_registration_token/reset.rb
+++ b/app/graphql/mutations/ci/runners_registration_token/reset.rb
@@ -45,20 +45,19 @@ module Mutations
def reset_token(type:, **args)
id = args[:id]
+ scope = nil
case type
when 'instance_type'
raise Gitlab::Graphql::Errors::ArgumentError, "id must not be specified for '#{type}' scope" if id.present?
- authorize!(:global)
-
- ApplicationSetting.current.reset_runners_registration_token!
- ApplicationSetting.current_without_cache.runners_registration_token
+ scope = ApplicationSetting.current
+ authorize!(scope)
when 'group_type', 'project_type'
- project_or_group = authorized_find!(type: type, id: id)
- project_or_group.reset_runners_token!
- project_or_group.runners_token
+ scope = authorized_find!(type: type, id: id)
end
+
+ ::Ci::Runners::ResetRegistrationTokenService.new(scope, current_user).execute if scope
end
end
end
diff --git a/app/graphql/mutations/concerns/mutations/spam_protection.rb b/app/graphql/mutations/concerns/mutations/spam_protection.rb
index 341067710b2..e61f66c02a5 100644
--- a/app/graphql/mutations/concerns/mutations/spam_protection.rb
+++ b/app/graphql/mutations/concerns/mutations/spam_protection.rb
@@ -16,30 +16,16 @@ module Mutations
private
- def spam_action_response(object)
- fields = spam_action_response_fields(object)
-
- # If the SpamActionService detected something as spam,
- # this is non-recoverable and the needs_captcha_response
- # should not be considered
- kind = if fields[:spam]
- :spam
- elsif fields[:needs_captcha_response]
- :needs_captcha_response
- end
-
- [kind, fields]
- end
-
def check_spam_action_response!(object)
- kind, fields = spam_action_response(object)
+ fields = spam_action_response_fields(object)
- case kind
- when :needs_captcha_response
+ if fields[:spam]
+ # If the SpamActionService detected something as spam, this is non-recoverable and the
+ # needs_captcha_response and other CAPTCHA-related fields should not be returned
+ raise SpamDisallowedError.new(SPAM_DISALLOWED_MESSAGE, extensions: { spam: true })
+ elsif fields[:needs_captcha_response]
fields.delete :spam
raise NeedsCaptchaResponseError.new(NEEDS_CAPTCHA_RESPONSE_MESSAGE, extensions: fields)
- when :spam
- raise SpamDisallowedError.new(SPAM_DISALLOWED_MESSAGE, extensions: { spam: true })
else
nil
end
diff --git a/app/graphql/mutations/notes/base.rb b/app/graphql/mutations/notes/base.rb
index d6c8121eee7..65bb9e4644c 100644
--- a/app/graphql/mutations/notes/base.rb
+++ b/app/graphql/mutations/notes/base.rb
@@ -3,6 +3,12 @@
module Mutations
module Notes
class Base < BaseMutation
+ QUICK_ACTION_ONLY_WARNING = <<~NB
+ If the body of the Note contains only quick actions,
+ the Note will be destroyed during an update, and no Note will be
+ returned.
+ NB
+
field :note,
Types::Notes::NoteType,
null: true,
diff --git a/app/graphql/mutations/notes/create/note.rb b/app/graphql/mutations/notes/create/note.rb
index 5a5d62a8c20..1cfc11c6b11 100644
--- a/app/graphql/mutations/notes/create/note.rb
+++ b/app/graphql/mutations/notes/create/note.rb
@@ -5,12 +5,18 @@ module Mutations
module Create
class Note < Base
graphql_name 'CreateNote'
+ description "Creates a Note.\n#{QUICK_ACTION_ONLY_WARNING}"
argument :discussion_id,
::Types::GlobalIDType[::Discussion],
required: false,
description: 'Global ID of the discussion this note is in reply to.'
+ argument :merge_request_diff_head_sha,
+ GraphQL::Types::String,
+ required: false,
+ description: 'SHA of the head commit which is used to ensure that the merge request has not been updated since the request was sent.'
+
private
def create_note_params(noteable, args)
@@ -28,7 +34,8 @@ module Mutations
end
super(noteable, args).merge({
- in_reply_to_discussion_id: discussion_id
+ in_reply_to_discussion_id: discussion_id,
+ merge_request_diff_head_sha: args[:merge_request_diff_head_sha]
})
end
diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb
index 2dfa7b815a1..4c6df2776cc 100644
--- a/app/graphql/mutations/notes/update/base.rb
+++ b/app/graphql/mutations/notes/update/base.rb
@@ -6,12 +6,6 @@ module Mutations
# This is a Base class for the Note update mutations and is not
# mounted as a GraphQL mutation itself.
class Base < Mutations::Notes::Base
- QUICK_ACTION_ONLY_WARNING = <<~NB
- If the body of the Note contains only quick actions,
- the Note will be destroyed during the update, and no Note will be
- returned.
- NB
-
authorize :admin_note
argument :id,
diff --git a/app/graphql/mutations/saved_replies/base.rb b/app/graphql/mutations/saved_replies/base.rb
new file mode 100644
index 00000000000..468263b0f9d
--- /dev/null
+++ b/app/graphql/mutations/saved_replies/base.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Mutations
+ module SavedReplies
+ class Base < BaseMutation
+ field :saved_reply, Types::SavedReplyType,
+ null: true,
+ description: 'Updated saved reply.'
+
+ private
+
+ def present_result(result)
+ if result.success?
+ {
+ saved_reply: result[:saved_reply],
+ errors: []
+ }
+ else
+ {
+ saved_reply: nil,
+ errors: result.message
+ }
+ end
+ end
+
+ def feature_enabled?
+ Feature.enabled?(:saved_replies, current_user, default_enabled: :yaml)
+ end
+
+ def find_object(id)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Users::SavedReply].coerce_isolated_input(id)
+
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/saved_replies/create.rb b/app/graphql/mutations/saved_replies/create.rb
new file mode 100644
index 00000000000..d97461a1c2a
--- /dev/null
+++ b/app/graphql/mutations/saved_replies/create.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Mutations
+ module SavedReplies
+ class Create < Base
+ graphql_name 'SavedReplyCreate'
+
+ authorize :create_saved_replies
+
+ argument :name, GraphQL::Types::String,
+ required: true,
+ description: copy_field_description(Types::SavedReplyType, :name)
+
+ argument :content, GraphQL::Types::String,
+ required: true,
+ description: copy_field_description(Types::SavedReplyType, :content)
+
+ def resolve(name:, content:)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled?
+
+ result = ::Users::SavedReplies::CreateService.new(current_user: current_user, name: name, content: content).execute
+ present_result(result)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/saved_replies/update.rb b/app/graphql/mutations/saved_replies/update.rb
new file mode 100644
index 00000000000..bacc6ceb39e
--- /dev/null
+++ b/app/graphql/mutations/saved_replies/update.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Mutations
+ module SavedReplies
+ class Update < Base
+ graphql_name 'SavedReplyUpdate'
+
+ authorize :update_saved_replies
+
+ argument :id, Types::GlobalIDType[::Users::SavedReply],
+ required: true,
+ description: copy_field_description(Types::SavedReplyType, :id)
+
+ argument :name, GraphQL::Types::String,
+ required: true,
+ description: copy_field_description(Types::SavedReplyType, :name)
+
+ argument :content, GraphQL::Types::String,
+ required: true,
+ description: copy_field_description(Types::SavedReplyType, :content)
+
+ def resolve(id:, name:, content:)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled?
+
+ saved_reply = authorized_find!(id)
+ result = ::Users::SavedReplies::UpdateService.new(current_user: current_user, saved_reply: saved_reply, name: name, content: content).execute
+ present_result(result)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb
index 81454db62b1..48f0f470988 100644
--- a/app/graphql/mutations/work_items/create.rb
+++ b/app/graphql/mutations/work_items/create.rb
@@ -33,7 +33,7 @@ module Mutations
def resolve(project_path:, **attributes)
project = authorized_find!(project_path)
- unless Feature.enabled?(:work_items, project)
+ unless Feature.enabled?(:work_items, project, default_enabled: :yaml)
return { errors: ['`work_items` feature flag disabled for this project'] }
end
diff --git a/app/graphql/mutations/work_items/create_from_task.rb b/app/graphql/mutations/work_items/create_from_task.rb
new file mode 100644
index 00000000000..16d1e646167
--- /dev/null
+++ b/app/graphql/mutations/work_items/create_from_task.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module Mutations
+ module WorkItems
+ class CreateFromTask < BaseMutation
+ graphql_name 'WorkItemCreateFromTask'
+
+ include Mutations::SpamProtection
+
+ description "Creates a work item from a task in another work item's description." \
+ " Available only when feature flag `work_items` is enabled. This feature is experimental and is subject to change without notice."
+
+ authorize :update_work_item
+
+ argument :id, ::Types::GlobalIDType[::WorkItem],
+ required: true,
+ description: 'Global ID of the work item.'
+ argument :work_item_data, ::Types::WorkItems::ConvertTaskInputType,
+ required: true,
+ description: 'Arguments necessary to convert a task into a work item.',
+ prepare: ->(attributes, _ctx) { attributes.to_h }
+
+ field :work_item, Types::WorkItemType,
+ null: true,
+ description: 'Updated work item.'
+
+ field :new_work_item, Types::WorkItemType,
+ null: true,
+ description: 'New work item created from task.'
+
+ def resolve(id:, work_item_data:)
+ work_item = authorized_find!(id: id)
+
+ unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml)
+ return { errors: ['`work_items` feature flag disabled for this project'] }
+ end
+
+ spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
+
+ result = ::WorkItems::CreateFromTaskService.new(
+ work_item: work_item,
+ current_user: current_user,
+ work_item_params: work_item_data,
+ spam_params: spam_params
+ ).execute
+
+ check_spam_action_response!(result[:work_item]) if result[:work_item]
+
+ response = { errors: result.errors }
+ response.merge!(work_item: work_item, new_work_item: result[:work_item]) if result.success?
+
+ response
+ end
+
+ private
+
+ def find_object(id:)
+ # TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/delete.rb b/app/graphql/mutations/work_items/delete.rb
index 71792a802c0..f32354878ec 100644
--- a/app/graphql/mutations/work_items/delete.rb
+++ b/app/graphql/mutations/work_items/delete.rb
@@ -20,7 +20,7 @@ module Mutations
def resolve(id:)
work_item = authorized_find!(id: id)
- unless Feature.enabled?(:work_items, work_item.project)
+ unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml)
return { errors: ['`work_items` feature flag disabled for this project'] }
end
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index 3ab9ba2d502..2700cbdb709 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -28,7 +28,7 @@ module Mutations
def resolve(id:, **attributes)
work_item = authorized_find!(id: id)
- unless Feature.enabled?(:work_items, work_item.project)
+ unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml)
return { errors: ['`work_items` feature flag disabled for this project'] }
end
diff --git a/app/graphql/queries/burndown_chart/burnup.query.graphql b/app/graphql/queries/burndown_chart/burnup.query.graphql
index 7a389a6def5..0795645f8b7 100644
--- a/app/graphql/queries/burndown_chart/burnup.query.graphql
+++ b/app/graphql/queries/burndown_chart/burnup.query.graphql
@@ -1,4 +1,9 @@
-query BurnupTimesSeriesData($id: ID!, $isIteration: Boolean = false, $weight: Boolean = false) {
+query BurnupTimesSeriesData(
+ $id: ID!
+ $isIteration: Boolean = false
+ $weight: Boolean = false
+ $fullPath: String
+) {
milestone(id: $id) @skip(if: $isIteration) {
__typename
id
@@ -37,7 +42,7 @@ query BurnupTimesSeriesData($id: ID!, $isIteration: Boolean = false, $weight: Bo
__typename
id
title
- report {
+ report(fullPath: $fullPath) {
__typename
burnupTimeSeries {
__typename
diff --git a/app/graphql/resolvers/blobs_resolver.rb b/app/graphql/resolvers/blobs_resolver.rb
index d0eb2deaf48..0704a845bb0 100644
--- a/app/graphql/resolvers/blobs_resolver.rb
+++ b/app/graphql/resolvers/blobs_resolver.rb
@@ -30,8 +30,17 @@ module Resolvers
return [] if repository.empty?
ref ||= repository.root_ref
+ validate_ref(ref)
repository.blobs_at(paths.map { |path| [ref, path] })
end
+
+ private
+
+ def validate_ref(ref)
+ unless Gitlab::GitRefValidator.validate(ref)
+ raise Gitlab::Graphql::Errors::ArgumentError, 'Ref is not valid'
+ end
+ end
end
end
diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb
index 387185b5171..f9d60650443 100644
--- a/app/graphql/resolvers/ci/config_resolver.rb
+++ b/app/graphql/resolvers/ci/config_resolver.rb
@@ -38,6 +38,8 @@ module Resolvers
.validate(content, dry_run: dry_run)
response(result).merge(merged_yaml: result.merged_yaml)
+ rescue GRPC::InvalidArgument => error
+ Gitlab::ErrorTracking.track_and_raise_exception(error, sha: sha)
end
private
diff --git a/app/graphql/resolvers/concerns/group_issuable_resolver.rb b/app/graphql/resolvers/concerns/group_issuable_resolver.rb
index 542ff5374ff..92d22409ff2 100644
--- a/app/graphql/resolvers/concerns/group_issuable_resolver.rb
+++ b/app/graphql/resolvers/concerns/group_issuable_resolver.rb
@@ -3,12 +3,21 @@
module GroupIssuableResolver
extend ActiveSupport::Concern
- class_methods do
- def include_subgroups(name_of_things)
- argument :include_subgroups, GraphQL::Types::Boolean,
- required: false,
- default_value: false,
- description: "Include #{name_of_things} belonging to subgroups"
- end
+ included do
+ argument :include_subgroups, GraphQL::Types::Boolean,
+ required: false,
+ default_value: false,
+ description: "Include #{issuable_collection_name} belonging to subgroups"
+
+ argument :include_archived, GraphQL::Types::Boolean,
+ required: false,
+ default_value: false,
+ description: "Return #{issuable_collection_name} from archived projects"
+ end
+
+ def resolve(**args)
+ args[:non_archived] = !args.delete(:include_archived)
+
+ super
end
end
diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
index 75f1ee478a8..a72b9a09118 100644
--- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -51,7 +51,8 @@ module ResolvesMergeRequests
milestone: [:milestone],
security_auto_fix: [:author],
head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }],
- timelogs: [:timelogs]
+ timelogs: [:timelogs],
+ committers: [merge_request_diff: [:merge_request_diff_commits]]
}
end
end
diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb
index 42c4c22a938..764ed9b15fd 100644
--- a/app/graphql/resolvers/concerns/resolves_pipelines.rb
+++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb
@@ -20,11 +20,22 @@ 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."
+
+ argument :updated_after, Types::TimeType,
+ required: false,
+ description: 'Pipelines updated after this date.'
+ argument :updated_before, Types::TimeType,
+ required: false,
+ description: 'Pipelines updated before this date.'
+
+ argument :username,
+ GraphQL::Types::String,
+ required: false,
+ description: "Filter pipelines by the user that triggered the pipeline."
end
class_methods do
diff --git a/app/graphql/resolvers/group_issues_resolver.rb b/app/graphql/resolvers/group_issues_resolver.rb
index 28f9266974f..05c5e803539 100644
--- a/app/graphql/resolvers/group_issues_resolver.rb
+++ b/app/graphql/resolvers/group_issues_resolver.rb
@@ -3,9 +3,11 @@
module Resolvers
class GroupIssuesResolver < BaseIssuesResolver
- include GroupIssuableResolver
+ def self.issuable_collection_name
+ 'issues'
+ end
- include_subgroups 'issues'
+ include GroupIssuableResolver
def ready?(**args)
if args.dig(:not, :release_tag).present?
diff --git a/app/graphql/resolvers/group_members/notification_email_resolver.rb b/app/graphql/resolvers/group_members/notification_email_resolver.rb
new file mode 100644
index 00000000000..6cff4fbf531
--- /dev/null
+++ b/app/graphql/resolvers/group_members/notification_email_resolver.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module GroupMembers
+ class NotificationEmailResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type GraphQL::Types::String, null: true
+
+ def resolve
+ authorize!
+
+ BatchLoader::GraphQL.for(object.user_id).batch do |user_ids, loader|
+ User.find(user_ids).each do |user|
+ loader.call(user.id, user.notification_email_for(object.group))
+ end
+ end
+ end
+
+ def authorize!
+ raise_resource_not_available_error! unless user_is_admin?
+ end
+
+ def user_is_admin?
+ context[:current_user].present? && context[:current_user].can_admin_all_resources?
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/group_merge_requests_resolver.rb b/app/graphql/resolvers/group_merge_requests_resolver.rb
index 34a4c67bc56..da1b6169c07 100644
--- a/app/graphql/resolvers/group_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/group_merge_requests_resolver.rb
@@ -2,13 +2,16 @@
module Resolvers
class GroupMergeRequestsResolver < MergeRequestsResolver
+ def self.issuable_collection_name
+ 'merge requests'
+ end
+
include GroupIssuableResolver
alias_method :group, :object
type Types::MergeRequestType.connection_type, null: true
- include_subgroups 'merge requests'
accept_assignee
accept_author
diff --git a/app/graphql/resolvers/topics_resolver.rb b/app/graphql/resolvers/topics_resolver.rb
index d8199f3d89b..68e2ff69282 100644
--- a/app/graphql/resolvers/topics_resolver.rb
+++ b/app/graphql/resolvers/topics_resolver.rb
@@ -10,9 +10,9 @@ module Resolvers
def resolve(**args)
if args[:search].present?
- ::Projects::Topic.search(args[:search]).order_by_total_projects_count
+ ::Projects::Topic.search(args[:search]).order_by_non_private_projects_count
else
- ::Projects::Topic.order_by_total_projects_count
+ ::Projects::Topic.order_by_non_private_projects_count
end
end
end
diff --git a/app/graphql/resolvers/work_item_resolver.rb b/app/graphql/resolvers/work_item_resolver.rb
new file mode 100644
index 00000000000..7cf52339815
--- /dev/null
+++ b/app/graphql/resolvers/work_item_resolver.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class WorkItemResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ authorize :read_work_item
+
+ type Types::WorkItemType, null: true
+
+ argument :id, ::Types::GlobalIDType[::WorkItem], required: true, description: 'Global ID of the work item.'
+
+ def resolve(id:)
+ work_item = authorized_find!(id: id)
+ return unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml)
+
+ work_item
+ end
+
+ private
+
+ def find_object(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/work_items/types_resolver.rb b/app/graphql/resolvers/work_items/types_resolver.rb
index b7a32e13423..67a9d57d42f 100644
--- a/app/graphql/resolvers/work_items/types_resolver.rb
+++ b/app/graphql/resolvers/work_items/types_resolver.rb
@@ -5,10 +5,20 @@ module Resolvers
class TypesResolver < BaseResolver
type Types::WorkItems::TypeType.connection_type, null: true
- def resolve
+ argument :taskable, ::GraphQL::Types::Boolean,
+ required: false,
+ description: 'If `true`, only taskable work item types will be returned.' \
+ ' Argument is experimental and can be removed in the future without notice.'
+
+ def resolve(taskable: nil)
+ return unless Feature.enabled?(:work_items, object, default_enabled: :yaml)
+
# This will require a finder in the future when groups/projects get their work item types
# All groups/projects use the default types for now
- ::WorkItems::Type.default.order_by_name_asc
+ base_scope = ::WorkItems::Type.default
+ base_scope = base_scope.by_type(:task) if taskable
+
+ base_scope.order_by_name_asc
end
end
end
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index 7495d46179c..43b7bbb419f 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -9,6 +9,7 @@ module Types
present_using ::AlertManagement::AlertPresenter
implements(Types::Notes::NoteableInterface)
+ implements(Types::TodoableInterface)
authorize :read_alert_management_alert
@@ -127,6 +128,12 @@ module Types
null: true,
description: 'Alert condition for Prometheus.'
+ field :web_url,
+ GraphQL::Types::String,
+ method: :details_url,
+ null: false,
+ description: 'URL of the alert.'
+
def notes
object.ordered_notes
end
diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb
index d70236f16f9..0224aeddac6 100644
--- a/app/graphql/types/base_enum.rb
+++ b/app/graphql/types/base_enum.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# rubocop:disable Graphql/GraphqlNamePosition
module Types
class BaseEnum < GraphQL::Schema::Enum
class CustomValue < GraphQL::Schema::EnumValue
@@ -37,7 +38,7 @@ module Types
description(enum_mod.description) if use_description
enum_mod.definition.each do |key, content|
- value(key.to_s.upcase, **content)
+ value(key.to_s.upcase, value: key.to_s, description: content[:description])
end
end
# rubocop: enable Graphql/Descriptions
diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb
index 733006369ea..7f4c49df429 100644
--- a/app/graphql/types/board_list_type.rb
+++ b/app/graphql/types/board_list_type.rb
@@ -14,18 +14,18 @@ module Types
null: false,
description: 'ID (global ID) of the list.'
- field :title, GraphQL::Types::String, null: false,
- description: 'Title of the list.'
- field :list_type, GraphQL::Types::String, null: false,
- description: 'Type of the list.'
- field :position, GraphQL::Types::Int, null: true,
- description: 'Position of list within the board.'
- field :label, Types::LabelType, null: true,
- description: 'Label of the list.'
field :collapsed, GraphQL::Types::Boolean, null: true,
description: 'Indicates if the list is collapsed for this user.'
field :issues_count, GraphQL::Types::Int, null: true,
description: 'Count of issues in the list.'
+ field :label, Types::LabelType, null: true,
+ description: 'Label of the list.'
+ field :list_type, GraphQL::Types::String, null: false,
+ description: 'Type of the list.'
+ field :position, GraphQL::Types::Int, null: true,
+ description: 'Position of list within the board.'
+ field :title, GraphQL::Types::String, null: false,
+ description: 'Title of the list.'
field :issues, ::Types::IssueType.connection_type, null: true,
description: 'Board issues.',
diff --git a/app/graphql/types/ci/analytics_type.rb b/app/graphql/types/ci/analytics_type.rb
index f52b9eae229..a77b8026f86 100644
--- a/app/graphql/types/ci/analytics_type.rb
+++ b/app/graphql/types/ci/analytics_type.rb
@@ -6,28 +6,28 @@ module Types
class AnalyticsType < BaseObject
graphql_name 'PipelineAnalytics'
- field :week_pipelines_totals, [GraphQL::Types::Int], null: true,
- description: 'Total weekly pipeline count.'
- field :week_pipelines_successful, [GraphQL::Types::Int], null: true,
- description: 'Total weekly successful pipeline count.'
- field :week_pipelines_labels, [GraphQL::Types::String], null: true,
- description: 'Labels for the weekly pipeline count.'
- field :month_pipelines_totals, [GraphQL::Types::Int], null: true,
- description: 'Total monthly pipeline count.'
- field :month_pipelines_successful, [GraphQL::Types::Int], null: true,
- description: 'Total monthly successful pipeline count.'
field :month_pipelines_labels, [GraphQL::Types::String], null: true,
description: 'Labels for the monthly pipeline count.'
- field :year_pipelines_totals, [GraphQL::Types::Int], null: true,
- description: 'Total yearly pipeline count.'
- field :year_pipelines_successful, [GraphQL::Types::Int], null: true,
- description: 'Total yearly successful pipeline count.'
- field :year_pipelines_labels, [GraphQL::Types::String], null: true,
- description: 'Labels for the yearly pipeline count.'
- field :pipeline_times_values, [GraphQL::Types::Int], null: true,
- description: 'Pipeline times.'
+ field :month_pipelines_successful, [GraphQL::Types::Int], null: true,
+ description: 'Total monthly successful pipeline count.'
+ field :month_pipelines_totals, [GraphQL::Types::Int], null: true,
+ description: 'Total monthly pipeline count.'
field :pipeline_times_labels, [GraphQL::Types::String], null: true,
description: 'Pipeline times labels.'
+ field :pipeline_times_values, [GraphQL::Types::Int], null: true,
+ description: 'Pipeline times.'
+ field :week_pipelines_labels, [GraphQL::Types::String], null: true,
+ description: 'Labels for the weekly pipeline count.'
+ field :week_pipelines_successful, [GraphQL::Types::Int], null: true,
+ description: 'Total weekly successful pipeline count.'
+ field :week_pipelines_totals, [GraphQL::Types::Int], null: true,
+ description: 'Total weekly pipeline count.'
+ field :year_pipelines_labels, [GraphQL::Types::String], null: true,
+ description: 'Labels for the yearly pipeline count.'
+ field :year_pipelines_successful, [GraphQL::Types::Int], null: true,
+ description: 'Total yearly successful pipeline count.'
+ field :year_pipelines_totals, [GraphQL::Types::Int], null: true,
+ description: 'Total yearly pipeline count.'
end
end
end
diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb
index 790deab8f68..e43af6f3e78 100644
--- a/app/graphql/types/ci/ci_cd_setting_type.rb
+++ b/app/graphql/types/ci/ci_cd_setting_type.rb
@@ -7,18 +7,18 @@ module Types
authorize :admin_project
+ field :job_token_scope_enabled, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates CI job tokens generated in this project have restricted access to resources.',
+ method: :job_token_scope_enabled?
+ field :keep_latest_artifact, GraphQL::Types::Boolean, null: true,
+ description: 'Whether to keep the latest builds artifacts.',
+ method: :keep_latest_artifacts_available?
field :merge_pipelines_enabled, GraphQL::Types::Boolean, null: true,
description: 'Whether merge pipelines are enabled.',
method: :merge_pipelines_enabled?
field :merge_trains_enabled, GraphQL::Types::Boolean, null: true,
description: 'Whether merge trains are enabled.',
method: :merge_trains_enabled?
- field :keep_latest_artifact, GraphQL::Types::Boolean, null: true,
- description: 'Whether to keep the latest builds artifacts.',
- method: :keep_latest_artifacts_available?
- field :job_token_scope_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates CI job tokens generated in this project have restricted access to resources.',
- method: :job_token_scope_enabled?
field :project, Types::ProjectType, null: true,
description: 'Project the CI/CD settings belong to.'
end
diff --git a/app/graphql/types/ci/config/group_type.rb b/app/graphql/types/ci/config/group_type.rb
index e5cb0d4e72f..19076fe9c20 100644
--- a/app/graphql/types/ci/config/group_type.rb
+++ b/app/graphql/types/ci/config/group_type.rb
@@ -7,10 +7,10 @@ module Types
class GroupType < BaseObject
graphql_name 'CiConfigGroup'
- field :name, GraphQL::Types::String, null: true,
- description: 'Name of the job group.'
field :jobs, Types::Ci::Config::JobType.connection_type, null: true,
description: 'Jobs in group.'
+ field :name, GraphQL::Types::String, null: true,
+ description: 'Name of the job group.'
field :size, GraphQL::Types::Int, null: true,
description: 'Size of the job group.'
end
diff --git a/app/graphql/types/ci/config/job_type.rb b/app/graphql/types/ci/config/job_type.rb
index 4cf6780ef60..20279143635 100644
--- a/app/graphql/types/ci/config/job_type.rb
+++ b/app/graphql/types/ci/config/job_type.rb
@@ -7,33 +7,33 @@ module Types
class JobType < BaseObject
graphql_name 'CiConfigJob'
- field :name, GraphQL::Types::String, null: true,
- description: 'Name of the job.'
- field :group_name, GraphQL::Types::String, null: true,
- description: 'Name of the job group.'
- field :stage, GraphQL::Types::String, null: true,
- description: 'Name of the job stage.'
- field :needs, Types::Ci::Config::NeedType.connection_type, null: true,
- description: 'Builds that must complete before the jobs run.'
+ field :after_script, [GraphQL::Types::String], null: true,
+ description: 'Override a set of commands that are executed after the job.'
field :allow_failure, GraphQL::Types::Boolean, null: true,
description: 'Allow job to fail.'
field :before_script, [GraphQL::Types::String], null: true,
description: 'Override a set of commands that are executed before the job.'
- field :script, [GraphQL::Types::String], null: true,
- description: 'Shell script that is executed by a runner.'
- field :after_script, [GraphQL::Types::String], null: true,
- description: 'Override a set of commands that are executed after the job.'
- field :when, GraphQL::Types::String, null: true,
- description: 'When to run the job.',
- resolver_method: :restrict_when_to_run_jobs
field :environment, GraphQL::Types::String, null: true,
description: 'Name of an environment to which the job deploys.'
field :except, Types::Ci::Config::JobRestrictionType, null: true,
description: 'Limit when jobs are not created.'
+ field :group_name, GraphQL::Types::String, null: true,
+ description: 'Name of the job group.'
+ field :name, GraphQL::Types::String, null: true,
+ description: 'Name of the job.'
+ field :needs, Types::Ci::Config::NeedType.connection_type, null: true,
+ description: 'Builds that must complete before the jobs run.'
field :only, Types::Ci::Config::JobRestrictionType, null: true,
description: 'Jobs are created when these conditions do not apply.'
+ field :script, [GraphQL::Types::String], null: true,
+ description: 'Shell script that is executed by a runner.'
+ field :stage, GraphQL::Types::String, null: true,
+ description: 'Name of the job stage.'
field :tags, [GraphQL::Types::String], null: true,
description: 'List of tags that are used to select a runner.'
+ field :when, GraphQL::Types::String, null: true,
+ description: 'When to run the job.',
+ resolver_method: :restrict_when_to_run_jobs
def restrict_when_to_run_jobs
object[:when]
diff --git a/app/graphql/types/ci/config/stage_type.rb b/app/graphql/types/ci/config/stage_type.rb
index 7e2aa9470f2..5b1163edac2 100644
--- a/app/graphql/types/ci/config/stage_type.rb
+++ b/app/graphql/types/ci/config/stage_type.rb
@@ -7,10 +7,10 @@ module Types
class StageType < BaseObject
graphql_name 'CiConfigStage'
- field :name, GraphQL::Types::String, null: true,
- description: 'Name of the stage.'
field :groups, Types::Ci::Config::GroupType.connection_type, null: true,
description: 'Groups of jobs for the stage.'
+ field :name, GraphQL::Types::String, null: true,
+ description: 'Name of the stage.'
end
end
end
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
index 4433e921971..e3413551a3f 100644
--- a/app/graphql/types/ci/detailed_status_type.rb
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -6,20 +6,23 @@ module Types
class DetailedStatusType < BaseObject
graphql_name 'DetailedStatus'
- field :id, GraphQL::Types::String, null: false,
- description: 'ID for a detailed status.',
- extras: [:parent]
- field :group, GraphQL::Types::String, null: true,
- description: 'Group of the status.'
- field :icon, GraphQL::Types::String, null: true,
- description: 'Icon of the status.'
- field :favicon, GraphQL::Types::String, null: true,
- description: 'Favicon of the status.'
+ field :action, Types::Ci::StatusActionType, null: true,
+ calls_gitaly: true,
+ description: 'Action information for the status. This includes method, button title, icon, path, and title.'
field :details_path, GraphQL::Types::String, null: true,
description: 'Path of the details for the status.'
+ field :favicon, GraphQL::Types::String, null: true,
+ description: 'Favicon of the status.'
+ field :group, GraphQL::Types::String, null: true,
+ description: 'Group of the status.'
field :has_details, GraphQL::Types::Boolean, null: true,
description: 'Indicates if the status has further details.',
method: :has_details?
+ field :icon, GraphQL::Types::String, null: true,
+ description: 'Icon of the status.'
+ field :id, GraphQL::Types::String, null: false,
+ description: 'ID for a detailed status.',
+ extras: [:parent]
field :label, GraphQL::Types::String, null: true,
calls_gitaly: true,
description: 'Label of the status.'
@@ -28,9 +31,6 @@ module Types
field :tooltip, GraphQL::Types::String, null: true,
description: 'Tooltip associated with the status.',
method: :status_tooltip
- field :action, Types::Ci::StatusActionType, null: true,
- calls_gitaly: true,
- description: 'Action information for the status. This includes method, button title, icon, path, and title.'
def id(parent:)
"#{object.id}-#{parent.object.object.id}"
diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb
index 3ae23ba9bd4..c3c73ef170c 100644
--- a/app/graphql/types/ci/group_type.rb
+++ b/app/graphql/types/ci/group_type.rb
@@ -6,16 +6,16 @@ module Types
class GroupType < BaseObject
graphql_name 'CiGroup'
+ field :detailed_status, Types::Ci::DetailedStatusType, null: true,
+ description: 'Detailed status of the group.'
field :id, GraphQL::Types::String, null: false,
description: 'ID for a group.'
+ field :jobs, Ci::JobType.connection_type, null: true,
+ description: 'Jobs in group.'
field :name, GraphQL::Types::String, null: true,
description: 'Name of the job group.'
field :size, GraphQL::Types::Int, null: true,
description: 'Size of the group.'
- field :jobs, Ci::JobType.connection_type, null: true,
- description: 'Jobs in group.'
- field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the group.'
def detailed_status
object.detailed_status(context[:current_user])
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 1320b96907e..83054553bd8 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -11,38 +11,38 @@ module Types
expose_permissions Types::PermissionTypes::Ci::Job
+ field :allow_failure, ::GraphQL::Types::Boolean, null: false,
+ description: 'Whether the job is allowed to fail.'
+ field :duration, GraphQL::Types::Int, null: true,
+ description: 'Duration of the job in seconds.'
field :id, ::Types::GlobalIDType[::CommitStatus].as('JobID'), null: true,
description: 'ID of the job.'
- field :pipeline, Types::Ci::PipelineType, null: true,
- description: 'Pipeline the job belongs to.'
field :name, GraphQL::Types::String, null: true,
description: 'Name of the job.'
field :needs, BuildNeedType.connection_type, null: true,
description: 'References to builds that must complete before the jobs run.'
+ field :pipeline, Types::Ci::PipelineType, null: true,
+ description: 'Pipeline the job belongs to.'
+ field :stage, Types::Ci::StageType, null: true,
+ description: 'Stage of the job.'
field :status,
type: ::Types::Ci::JobStatusEnum,
null: true,
description: "Status of the job."
- field :stage, Types::Ci::StageType, null: true,
- description: 'Stage of the job.'
- field :allow_failure, ::GraphQL::Types::Boolean, null: false,
- description: 'Whether the job is allowed to fail.'
- field :duration, GraphQL::Types::Int, null: true,
- description: 'Duration of the job in seconds.'
field :tags, [GraphQL::Types::String], null: true,
description: 'Tags for the current job.'
# Life-cycle timestamps:
field :created_at, Types::TimeType, null: false,
description: "When the job was created."
- field :queued_at, Types::TimeType, null: true,
- description: 'When the job was enqueued and marked as pending.'
- field :started_at, Types::TimeType, null: true,
- description: 'When the job was started.'
field :finished_at, Types::TimeType, null: true,
description: 'When a job has finished running.'
+ field :queued_at, Types::TimeType, null: true,
+ description: 'When the job was enqueued and marked as pending.'
field :scheduled_at, Types::TimeType, null: true,
description: 'Schedule for the build.'
+ field :started_at, Types::TimeType, null: true,
+ description: 'When the job was started.'
# Life-cycle durations:
field :queued_duration,
@@ -50,40 +50,40 @@ module Types
null: true,
description: 'How long the job was enqueued before starting.'
- field :downstream_pipeline, Types::Ci::PipelineType, null: true,
- description: 'Downstream pipeline for a bridge.'
- field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true,
- description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.'
- field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the job.'
+ field :active, GraphQL::Types::Boolean, null: false, method: :active?,
+ description: 'Indicates the job is active.'
field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true,
description: 'Artifacts generated by the job.'
- field :short_sha, type: GraphQL::Types::String, null: false,
- description: 'Short SHA1 ID of the commit.'
- field :scheduling_type, GraphQL::Types::String, null: true,
- description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.'
+ field :cancelable, GraphQL::Types::Boolean, null: false, method: :cancelable?,
+ description: 'Indicates the job can be canceled.'
field :commit_path, GraphQL::Types::String, null: true,
description: 'Path to the commit that triggered the job.'
+ field :coverage, GraphQL::Types::Float, null: true,
+ description: 'Coverage level of the job.'
+ field :created_by_tag, GraphQL::Types::Boolean, null: false,
+ description: 'Whether the job was created by a tag.', method: :tag?
+ field :detailed_status, Types::Ci::DetailedStatusType, null: true,
+ description: 'Detailed status of the job.'
+ field :downstream_pipeline, Types::Ci::PipelineType, null: true,
+ description: 'Downstream pipeline for a bridge.'
+ field :manual_job, GraphQL::Types::Boolean, null: true,
+ description: 'Whether the job has a manual action.'
+ field :playable, GraphQL::Types::Boolean, null: false, method: :playable?,
+ description: 'Indicates the job can be played.'
+ field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true,
+ description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.'
field :ref_name, GraphQL::Types::String, null: true,
description: 'Ref name of the job.'
field :ref_path, GraphQL::Types::String, null: true,
description: 'Path to the ref.'
- field :playable, GraphQL::Types::Boolean, null: false, method: :playable?,
- description: 'Indicates the job can be played.'
field :retryable, GraphQL::Types::Boolean, null: false, method: :retryable?,
description: 'Indicates the job can be retried.'
- field :cancelable, GraphQL::Types::Boolean, null: false, method: :cancelable?,
- description: 'Indicates the job can be canceled.'
- field :active, GraphQL::Types::Boolean, null: false, method: :active?,
- description: 'Indicates the job is active.'
+ field :scheduling_type, GraphQL::Types::String, null: true,
+ description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.'
+ field :short_sha, type: GraphQL::Types::String, null: false,
+ description: 'Short SHA1 ID of the commit.'
field :stuck, GraphQL::Types::Boolean, null: false, method: :stuck?,
description: 'Indicates the job is stuck.'
- field :coverage, GraphQL::Types::Float, null: true,
- description: 'Coverage level of the job.'
- field :created_by_tag, GraphQL::Types::Boolean, null: false,
- description: 'Whether the job was created by a tag.'
- field :manual_job, GraphQL::Types::Boolean, null: true,
- description: 'Whether the job has a manual action.'
field :triggered, GraphQL::Types::Boolean, null: true,
description: 'Whether the job was triggered.'
@@ -173,10 +173,6 @@ module Types
object&.coverage
end
- def created_by_tag
- object.tag?
- end
-
def manual_job
object.try(:action?)
end
diff --git a/app/graphql/types/ci/runner_architecture_type.rb b/app/graphql/types/ci/runner_architecture_type.rb
index 08d3f98592b..eb576cf09ce 100644
--- a/app/graphql/types/ci/runner_architecture_type.rb
+++ b/app/graphql/types/ci/runner_architecture_type.rb
@@ -6,10 +6,10 @@ module Types
class RunnerArchitectureType < BaseObject
graphql_name 'RunnerArchitecture'
- field :name, GraphQL::Types::String, null: false,
- description: 'Name of the runner platform architecture.'
field :download_location, GraphQL::Types::String, null: false,
description: 'Download location for the runner for the platform architecture.'
+ field :name, GraphQL::Types::String, null: false,
+ description: 'Name of the runner platform architecture.'
end
end
end
diff --git a/app/graphql/types/ci/runner_platform_type.rb b/app/graphql/types/ci/runner_platform_type.rb
index ffcf6364968..3c893615b20 100644
--- a/app/graphql/types/ci/runner_platform_type.rb
+++ b/app/graphql/types/ci/runner_platform_type.rb
@@ -6,12 +6,12 @@ module Types
class RunnerPlatformType < BaseObject
graphql_name 'RunnerPlatform'
- field :name, GraphQL::Types::String, null: false,
- description: 'Name slug of the runner platform.'
- field :human_readable_name, GraphQL::Types::String, null: false,
- description: 'Human readable name of the runner platform.'
field :architectures, Types::Ci::RunnerArchitectureType.connection_type, null: true,
description: 'Runner architectures supported for the platform.'
+ field :human_readable_name, GraphQL::Types::String, null: false,
+ description: 'Human readable name of the runner platform.'
+ field :name, GraphQL::Types::String, null: false,
+ description: 'Name slug of the runner platform.'
end
end
end
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 9094c6b96e4..a7f0730f07e 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -16,54 +16,20 @@ module Types
alias_method :runner, :object
- field :id, ::Types::GlobalIDType[::Ci::Runner], null: false,
- description: 'ID of the runner.'
- field :description, GraphQL::Types::String, null: true,
- description: 'Description of the runner.'
- field :created_at, Types::TimeType, null: true,
- description: 'Timestamp of creation of this runner.'
- field :contacted_at, Types::TimeType, null: true,
- description: 'Timestamp of last contact from this runner.',
- method: :contacted_at
- field :token_expires_at, Types::TimeType, null: true,
- description: 'Runner token expiration time.',
- method: :token_expires_at
- field :maximum_timeout, GraphQL::Types::Int, null: true,
- description: 'Maximum timeout (in seconds) for jobs processed by the runner.'
field :access_level, ::Types::Ci::RunnerAccessLevelEnum, null: false,
description: 'Access level of the runner.'
field :active, GraphQL::Types::Boolean, null: false,
description: 'Indicates the runner is allowed to receive jobs.',
deprecated: { reason: 'Use paused', milestone: '14.8' }
- field :paused, GraphQL::Types::Boolean, null: false,
- description: 'Indicates the runner is paused and not available to run jobs.'
- field :status,
- Types::Ci::RunnerStatusEnum,
- null: false,
- description: 'Status of the runner.',
- resolver: ::Resolvers::Ci::RunnerStatusResolver
- field :version, GraphQL::Types::String, null: true,
- description: 'Version of the runner.'
- field :short_sha, GraphQL::Types::String, null: true,
- description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.)
- field :revision, GraphQL::Types::String, null: true,
- description: 'Revision of the runner.'
- field :locked, GraphQL::Types::Boolean, null: true,
- description: 'Indicates the runner is locked.'
- field :run_untagged, GraphQL::Types::Boolean, null: false,
- description: 'Indicates the runner is able to run untagged jobs.'
- field :ip_address, GraphQL::Types::String, null: true,
- description: 'IP address of the runner.'
- field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false,
- description: 'Type of the runner.'
- field :tag_list, [GraphQL::Types::String], null: true,
- description: 'Tags associated with the runner.'
- field :project_count, GraphQL::Types::Int, null: true,
- description: 'Number of projects that the runner is associated with.'
- field :job_count, GraphQL::Types::Int, null: true,
- description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)."
field :admin_url, GraphQL::Types::String, null: true,
description: 'Admin URL of the runner. Only available for administrators.'
+ field :contacted_at, Types::TimeType, null: true,
+ description: 'Timestamp of last contact from this runner.',
+ method: :contacted_at
+ field :created_at, Types::TimeType, null: true,
+ description: 'Timestamp of creation of this runner.'
+ field :description, GraphQL::Types::String, null: true,
+ description: 'Description of the runner.'
field :edit_admin_url, GraphQL::Types::String, null: true,
description: 'Admin form URL of the runner. Only available for administrators.'
field :executor_name, GraphQL::Types::String, null: true,
@@ -72,12 +38,46 @@ module Types
feature_flag: :graphql_ci_runner_executor
field :groups, ::Types::GroupType.connection_type, null: true,
description: 'Groups the runner is associated with. For group runners only.'
- field :projects, ::Types::ProjectType.connection_type, null: true,
- description: 'Projects the runner is associated with. For project runners only.'
+ field :id, ::Types::GlobalIDType[::Ci::Runner], null: false,
+ description: 'ID of the runner.'
+ field :ip_address, GraphQL::Types::String, null: true,
+ description: 'IP address of the runner.'
+ field :job_count, GraphQL::Types::Int, null: true,
+ description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)."
field :jobs, ::Types::Ci::JobType.connection_type, null: true,
description: 'Jobs assigned to the runner.',
authorize: :read_builds,
resolver: ::Resolvers::Ci::RunnerJobsResolver
+ field :locked, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates the runner is locked.'
+ field :maximum_timeout, GraphQL::Types::Int, null: true,
+ description: 'Maximum timeout (in seconds) for jobs processed by the runner.'
+ field :paused, GraphQL::Types::Boolean, null: false,
+ description: 'Indicates the runner is paused and not available to run jobs.'
+ field :project_count, GraphQL::Types::Int, null: true,
+ description: 'Number of projects that the runner is associated with.'
+ field :projects, ::Types::ProjectType.connection_type, null: true,
+ description: 'Projects the runner is associated with. For project runners only.'
+ field :revision, GraphQL::Types::String, null: true,
+ description: 'Revision of the runner.'
+ field :run_untagged, GraphQL::Types::Boolean, null: false,
+ description: 'Indicates the runner is able to run untagged jobs.'
+ field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false,
+ description: 'Type of the runner.'
+ field :short_sha, GraphQL::Types::String, null: true,
+ description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.)
+ field :status,
+ Types::Ci::RunnerStatusEnum,
+ null: false,
+ description: 'Status of the runner.',
+ resolver: ::Resolvers::Ci::RunnerStatusResolver
+ field :tag_list, [GraphQL::Types::String], null: true,
+ description: 'Tags associated with the runner.'
+ field :token_expires_at, Types::TimeType, null: true,
+ description: 'Runner token expiration time.',
+ method: :token_expires_at
+ field :version, GraphQL::Types::String, null: true,
+ description: 'Version of the runner.'
def job_count
# We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT
diff --git a/app/graphql/types/ci/runner_web_url_edge.rb b/app/graphql/types/ci/runner_web_url_edge.rb
index 368e16f972c..035d75c22c6 100644
--- a/app/graphql/types/ci/runner_web_url_edge.rb
+++ b/app/graphql/types/ci/runner_web_url_edge.rb
@@ -6,6 +6,9 @@ module Types
class RunnerWebUrlEdge < ::Types::BaseEdge
include FindClosest
+ field :edit_url, GraphQL::Types::String, null: true,
+ description: 'Web URL of the runner edit page. The value depends on where you put this field in the query. You can use it for projects or groups.',
+ extras: [:parent]
field :web_url, GraphQL::Types::String, null: true,
description: 'Web URL of the runner. The value depends on where you put this field in the query. You can use it for projects or groups.',
extras: [:parent]
@@ -16,14 +19,26 @@ module Types
@runner = node.node
end
+ def edit_url(parent:)
+ runner_url(parent: parent, url_type: :edit_url)
+ end
+
def web_url(parent:)
+ runner_url(parent: parent, url_type: :default)
+ end
+
+ private
+
+ def runner_url(parent:, url_type: :default)
owner = closest_parent([::Types::ProjectType, ::Types::GroupType], parent)
+ # Only ::Group is supported at the moment, future iterations will include ::Project.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/16338
case owner
when ::Group
+ return Gitlab::Routing.url_helpers.edit_group_runner_url(owner, @runner) if url_type == :edit_url
+
Gitlab::Routing.url_helpers.group_runner_url(owner, @runner)
- when ::Project
- Gitlab::Routing.url_helpers.project_runner_url(owner, @runner)
end
end
end
diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb
index 70e78e391a7..dcb3092d15a 100644
--- a/app/graphql/types/ci/stage_type.rb
+++ b/app/graphql/types/ci/stage_type.rb
@@ -6,17 +6,17 @@ module Types
graphql_name 'CiStage'
authorize :read_build
- field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the stage.'
- field :name, type: GraphQL::Types::String, null: true,
- description: 'Name of the stage.'
+ field :detailed_status, Types::Ci::DetailedStatusType, null: true,
+ description: 'Detailed status of the stage.'
field :groups, type: Ci::GroupType.connection_type, null: true,
extras: [:lookahead],
description: 'Group of jobs for the stage.'
- field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the stage.'
+ field :id, GraphQL::Types::ID, null: false,
+ description: 'ID of the stage.'
field :jobs, Types::Ci::JobType.connection_type, null: true,
description: 'Jobs for the stage.'
+ field :name, type: GraphQL::Types::String, null: true,
+ description: 'Name of the stage.'
field :status, GraphQL::Types::String,
null: true,
description: 'Status of the pipeline stage.'
diff --git a/app/graphql/types/ci/status_action_type.rb b/app/graphql/types/ci/status_action_type.rb
index 15e5344e130..26ca3c1438a 100644
--- a/app/graphql/types/ci/status_action_type.rb
+++ b/app/graphql/types/ci/status_action_type.rb
@@ -5,13 +5,13 @@ module Types
class StatusActionType < BaseObject
graphql_name 'StatusAction'
- field :id, GraphQL::Types::String, null: false,
- description: 'ID for a status action.',
- extras: [:parent]
field :button_title, GraphQL::Types::String, null: true,
description: 'Title for the button, for example: Retry this job.'
field :icon, GraphQL::Types::String, null: true,
description: 'Icon used in the action button.'
+ field :id, GraphQL::Types::String, null: false,
+ description: 'ID for a status action.',
+ extras: [:parent]
field :method, GraphQL::Types::String, null: true,
description: 'Method for the action, for example: :post.',
resolver_method: :action_method
diff --git a/app/graphql/types/ci/template_type.rb b/app/graphql/types/ci/template_type.rb
index 7e7ee44025f..4f1ec6436de 100644
--- a/app/graphql/types/ci/template_type.rb
+++ b/app/graphql/types/ci/template_type.rb
@@ -7,10 +7,10 @@ module Types
graphql_name 'CiTemplate'
description 'GitLab CI/CD configuration template.'
- field :name, GraphQL::Types::String, null: false,
- description: 'Name of the CI template.'
field :content, GraphQL::Types::String, null: false,
description: 'Contents of the CI template.'
+ field :name, GraphQL::Types::String, null: false,
+ description: 'Name of the CI template.'
end
end
end
diff --git a/app/graphql/types/commit_action_type.rb b/app/graphql/types/commit_action_type.rb
index 6f6d6a418dc..1aa3a4e7ee1 100644
--- a/app/graphql/types/commit_action_type.rb
+++ b/app/graphql/types/commit_action_type.rb
@@ -4,17 +4,17 @@ module Types
class CommitActionType < BaseInputObject
argument :action, type: Types::CommitActionModeEnum, required: true,
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,
description: 'Content of the file.'
- argument :previous_path, type: GraphQL::Types::String, required: false,
- description: 'Original full path to the file being moved.'
- argument :last_commit_id, type: GraphQL::Types::String, required: false,
- description: 'Last known file commit ID.'
- argument :execute_filemode, type: GraphQL::Types::Boolean, required: false,
- description: 'Enables/disables the execute flag on the file.'
argument :encoding, type: Types::CommitEncodingEnum, required: false,
description: 'Encoding of the file. Default is text.'
+ argument :execute_filemode, type: GraphQL::Types::Boolean, required: false,
+ description: 'Enables/disables the execute flag on the file.'
+ argument :file_path, type: GraphQL::Types::String, required: true,
+ description: 'Full path to the file.'
+ argument :last_commit_id, type: GraphQL::Types::String, required: false,
+ description: 'Last known file commit ID.'
+ argument :previous_path, type: GraphQL::Types::String, required: false,
+ description: 'Original full path to the file being moved.'
end
end
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index 8bc00359ccb..c3a6d6f7faa 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -8,6 +8,8 @@ module Types
present_using CommitPresenter
+ implements(Types::TodoableInterface)
+
field :id, type: GraphQL::Types::ID, null: false,
description: 'ID (global ID) of the commit.'
@@ -41,12 +43,12 @@ module Types
field :signature_html, type: GraphQL::Types::String, null: true, calls_gitaly: true,
description: 'Rendered HTML of the commit signature.'
- field :author_name, type: GraphQL::Types::String, null: true,
- description: 'Commit authors name.'
field :author_email, type: GraphQL::Types::String, null: true,
description: "Commit author's email."
field :author_gravatar, type: GraphQL::Types::String, null: true,
description: 'Commit authors gravatar.'
+ field :author_name, type: GraphQL::Types::String, null: true,
+ description: 'Commit authors name.'
# models/commit lazy loads the author by email
field :author, type: Types::UserType, null: true,
diff --git a/app/graphql/types/container_expiration_policy_type.rb b/app/graphql/types/container_expiration_policy_type.rb
index 6d6df21fe3f..0e9534be684 100644
--- a/app/graphql/types/container_expiration_policy_type.rb
+++ b/app/graphql/types/container_expiration_policy_type.rb
@@ -8,14 +8,14 @@ module Types
authorize :destroy_container_image
+ field :cadence, Types::ContainerExpirationPolicyCadenceEnum, null: false, description: 'This container expiration policy schedule.'
field :created_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was created.'
- field :updated_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was updated.'
field :enabled, GraphQL::Types::Boolean, null: false, description: 'Indicates whether this container expiration policy is enabled.'
- field :older_than, Types::ContainerExpirationPolicyOlderThanEnum, null: true, description: 'Tags older that this will expire.'
- field :cadence, Types::ContainerExpirationPolicyCadenceEnum, null: false, description: 'This container expiration policy schedule.'
field :keep_n, Types::ContainerExpirationPolicyKeepEnum, null: true, description: 'Number of tags to retain.'
field :name_regex, Types::UntrustedRegexp, null: true, description: 'Tags with names matching this regex pattern will expire.'
field :name_regex_keep, Types::UntrustedRegexp, null: true, description: 'Tags with names matching this regex pattern will be preserved.'
field :next_run_at, Types::TimeType, null: true, description: 'Next time that this container expiration policy will get executed.'
+ field :older_than, Types::ContainerExpirationPolicyOlderThanEnum, null: true, description: 'Tags older that this will expire.'
+ field :updated_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was updated.'
end
end
diff --git a/app/graphql/types/container_repository_details_type.rb b/app/graphql/types/container_repository_details_type.rb
index e713aaebe36..1ee9e76a1c8 100644
--- a/app/graphql/types/container_repository_details_type.rb
+++ b/app/graphql/types/container_repository_details_type.rb
@@ -15,8 +15,19 @@ module Types
max_page_size: 20,
resolver: Resolvers::ContainerRepositoryTagsResolver
+ field :size,
+ GraphQL::Types::Float,
+ null: true,
+ description: 'Deduplicated size of the image repository in bytes. This is only available on GitLab.com for repositories created after `2021-11-04`.'
+
def can_delete
Ability.allowed?(current_user, :destroy_container_image, object)
end
+
+ def size
+ object.size
+ rescue Faraday::Error
+ raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, "Can't connect to the Container Registry. If this error persists, please review the troubleshooting documentation."
+ end
end
end
diff --git a/app/graphql/types/container_repository_tag_type.rb b/app/graphql/types/container_repository_tag_type.rb
index 206d6a3426c..d9665175449 100644
--- a/app/graphql/types/container_repository_tag_type.rb
+++ b/app/graphql/types/container_repository_tag_type.rb
@@ -8,15 +8,15 @@ module Types
authorize :read_container_image
+ field :can_delete, GraphQL::Types::Boolean, null: false, description: 'Can the current user delete this tag.'
+ field :created_at, Types::TimeType, null: true, description: 'Timestamp when the tag was created.'
+ field :digest, GraphQL::Types::String, null: true, description: 'Digest of the tag.'
+ field :location, GraphQL::Types::String, null: false, description: 'URL of the tag.'
field :name, GraphQL::Types::String, null: false, description: 'Name of the tag.'
field :path, GraphQL::Types::String, null: false, description: 'Path of the tag.'
- field :location, GraphQL::Types::String, null: false, description: 'URL of the tag.'
- 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: '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.'
def can_delete
Ability.allowed?(current_user, :destroy_container_image, object)
diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb
index 1fe5cf112f0..3cd3730010b 100644
--- a/app/graphql/types/container_repository_type.rb
+++ b/app/graphql/types/container_repository_type.rb
@@ -8,18 +8,18 @@ module Types
authorize :read_container_image
+ field :can_delete, GraphQL::Types::Boolean, null: false, description: 'Can the current user delete the container repository.'
+ field :created_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was created.'
+ field :expiration_policy_cleanup_status, Types::ContainerRepositoryCleanupStatusEnum, null: true, description: 'Tags cleanup status for the container repository.'
+ 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 :id, GraphQL::Types::ID, null: false, description: 'ID of the container repository.'
+ field :location, GraphQL::Types::String, null: false, description: 'URL of the container repository.'
field :name, GraphQL::Types::String, null: false, description: 'Name of the container repository.'
field :path, GraphQL::Types::String, null: false, description: 'Path of the container repository.'
- field :location, GraphQL::Types::String, null: false, description: 'URL of the container repository.'
- field :created_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was created.'
- field :updated_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was updated.'
- field :expiration_policy_started_at, Types::TimeType, null: true, description: 'Timestamp when the cleanup done by the expiration policy was started on the container repository.'
- field :expiration_policy_cleanup_status, Types::ContainerRepositoryCleanupStatusEnum, null: true, description: 'Tags cleanup status for the container repository.'
+ field :project, Types::ProjectType, null: false, description: 'Project of the container registry.'
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.'
- field :project, Types::ProjectType, null: false, description: 'Project of the container registry.'
+ field :updated_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was updated.'
def can_delete
Ability.allowed?(current_user, :update_container_image, object)
diff --git a/app/graphql/types/dependency_proxy/blob_type.rb b/app/graphql/types/dependency_proxy/blob_type.rb
index f5a78fbb3ba..b5cebe516aa 100644
--- a/app/graphql/types/dependency_proxy/blob_type.rb
+++ b/app/graphql/types/dependency_proxy/blob_type.rb
@@ -9,8 +9,8 @@ module Types
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.'
+ field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
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
index 29bba7122d0..9ab7c50998d 100644
--- a/app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb
+++ b/app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb
@@ -8,9 +8,9 @@ module Types
authorize :read_dependency_proxy
+ field :created_at, Types::TimeType, null: true, description: 'Timestamp of creation.'
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
index ef9f730df43..ab22f540f48 100644
--- a/app/graphql/types/dependency_proxy/manifest_type.rb
+++ b/app/graphql/types/dependency_proxy/manifest_type.rb
@@ -8,13 +8,13 @@ module Types
authorize :read_dependency_proxy
- field :id, ::Types::GlobalIDType[::DependencyProxy::Manifest], null: false, description: 'ID of the manifest.'
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 :digest, GraphQL::Types::String, null: false, description: 'Digest of the manifest.'
field :file_name, GraphQL::Types::String, null: false, description: 'Name of the manifest.'
+ field :id, ::Types::GlobalIDType[::DependencyProxy::Manifest], null: false, description: 'ID 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.'
+ field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
def image_name
object.file_name.chomp(File.extname(object.file_name))
diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb
index 570eac907f3..91978aa37b0 100644
--- a/app/graphql/types/design_management/design_collection_type.rb
+++ b/app/graphql/types/design_management/design_collection_type.rb
@@ -8,10 +8,10 @@ module Types
authorize :read_design
- field :project, Types::ProjectType, null: false,
- description: 'Project associated with the design collection.'
field :issue, Types::IssueType, null: false,
description: 'Issue associated with the design collection.'
+ field :project, Types::ProjectType, null: false,
+ description: 'Project associated with the design collection.'
field :designs,
Types::DesignManagement::DesignType.connection_type,
diff --git a/app/graphql/types/design_management/design_fields.rb b/app/graphql/types/design_management/design_fields.rb
index 75f1aaa8c60..364f72a519f 100644
--- a/app/graphql/types/design_management/design_fields.rb
+++ b/app/graphql/types/design_management/design_fields.rb
@@ -62,7 +62,7 @@ module Types
def cached_actions_for_version(version)
Gitlab::SafeRequestStore.fetch(['DesignFields', 'actions_for_version', version.id]) do
- version.actions.to_h { |dv| [dv.design_id, dv] }
+ version.actions.index_by(&:design_id)
end
end
diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb
index 2f40bf5ebfd..4c0b1162306 100644
--- a/app/graphql/types/design_management/design_type.rb
+++ b/app/graphql/types/design_management/design_type.rb
@@ -13,6 +13,12 @@ module Types
implements(Types::Notes::NoteableInterface)
implements(Types::DesignManagement::DesignFields)
implements(Types::CurrentUserTodos)
+ implements(Types::TodoableInterface)
+
+ field :web_url,
+ GraphQL::Types::String,
+ null: false,
+ description: 'URL of the design.'
field :versions,
Types::DesignManagement::VersionType.connection_type,
@@ -40,6 +46,10 @@ module Types
def request_cache_base_key
self.class.name
end
+
+ def web_url
+ Gitlab::UrlBuilder.build(object)
+ end
end
end
end
diff --git a/app/graphql/types/diff_paths_input_type.rb b/app/graphql/types/diff_paths_input_type.rb
index cdcff1a7e34..c5c75105fda 100644
--- a/app/graphql/types/diff_paths_input_type.rb
+++ b/app/graphql/types/diff_paths_input_type.rb
@@ -2,9 +2,9 @@
module Types
class DiffPathsInputType < BaseInputObject
- argument :old_path, GraphQL::Types::String, required: false,
- description: 'Path of the file on the start SHA.'
argument :new_path, GraphQL::Types::String, required: false,
description: 'Path of the file on the HEAD SHA.'
+ argument :old_path, GraphQL::Types::String, required: false,
+ description: 'Path of the file on the start SHA.'
end
end
diff --git a/app/graphql/types/diff_refs_type.rb b/app/graphql/types/diff_refs_type.rb
index b19d09c789c..a03d72a4dc2 100644
--- a/app/graphql/types/diff_refs_type.rb
+++ b/app/graphql/types/diff_refs_type.rb
@@ -6,10 +6,10 @@ module Types
class DiffRefsType < BaseObject
graphql_name 'DiffRefs'
- field :head_sha, GraphQL::Types::String, null: false,
- description: 'SHA of the HEAD at the time the comment was made.'
field :base_sha, GraphQL::Types::String, null: true,
description: 'Merge base of the branch the comment was made on.'
+ field :head_sha, GraphQL::Types::String, null: false,
+ description: 'SHA of the HEAD at the time the comment was made.'
field :start_sha, GraphQL::Types::String, null: false,
description: 'SHA of the branch being compared against.'
end
diff --git a/app/graphql/types/diff_stats_summary_type.rb b/app/graphql/types/diff_stats_summary_type.rb
index 079c73d0759..95705ddecf3 100644
--- a/app/graphql/types/diff_stats_summary_type.rb
+++ b/app/graphql/types/diff_stats_summary_type.rb
@@ -10,10 +10,10 @@ module Types
field :additions, GraphQL::Types::Int, null: false,
description: 'Number of lines added.'
- field :deletions, GraphQL::Types::Int, null: false,
- description: 'Number of lines deleted.'
field :changes, GraphQL::Types::Int, null: false,
description: 'Number of lines changed.'
+ field :deletions, GraphQL::Types::Int, null: false,
+ description: 'Number of lines deleted.'
field :file_count, GraphQL::Types::Int, null: false,
description: 'Number of files changed.'
diff --git a/app/graphql/types/diff_stats_type.rb b/app/graphql/types/diff_stats_type.rb
index 60aacca8ce5..da366fec8c3 100644
--- a/app/graphql/types/diff_stats_type.rb
+++ b/app/graphql/types/diff_stats_type.rb
@@ -8,12 +8,12 @@ module Types
description 'Changes to a single file'
- field :path, GraphQL::Types::String, null: false,
- description: 'File path, relative to repository root.'
field :additions, GraphQL::Types::Int, null: false,
description: 'Number of lines added to this file.'
field :deletions, GraphQL::Types::Int, null: false,
description: 'Number of lines deleted from this file.'
+ field :path, GraphQL::Types::String, null: false,
+ description: 'File path, relative to repository root.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
index 826ae61a1a3..b19ab80f96d 100644
--- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
+++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
@@ -10,46 +10,68 @@ module Types
authorize :read_sentry_issue
- field :id, GraphQL::Types::ID,
- null: false,
- description: 'ID (global ID) of the error.'
- field :integrated, GraphQL::Types::Boolean,
- null: true,
- description: 'Error tracking backend.'
- field :sentry_id, GraphQL::Types::String,
- method: :id,
- null: false,
- description: 'ID (Sentry ID) of the error.'
- field :title, GraphQL::Types::String,
+ field :count, GraphQL::Types::Int,
null: false,
- description: 'Title of the error.'
- field :type, GraphQL::Types::String,
+ description: 'Count of occurrences.'
+ field :culprit, GraphQL::Types::String,
null: false,
- description: 'Type of the error.'
- field :user_count, GraphQL::Types::Int,
+ description: 'Culprit of the error.'
+ field :external_base_url, GraphQL::Types::String,
null: false,
- description: 'Count of users affected by the error.'
- field :count, GraphQL::Types::Int,
+ description: 'External Base URL of the Sentry Instance.'
+ field :external_url, GraphQL::Types::String,
null: false,
- description: 'Count of occurrences.'
+ description: 'External URL of the error.'
+ field :first_release_last_commit, GraphQL::Types::String,
+ null: true,
+ description: 'Commit the error was first seen.'
+ field :first_release_short_version, GraphQL::Types::String,
+ null: true,
+ description: 'Release short version the error was first seen.'
+ field :first_release_version, GraphQL::Types::String,
+ null: true,
+ description: 'Release version the error was first seen.'
field :first_seen, Types::TimeType,
null: false,
description: 'Timestamp when the error was first seen.'
+ field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType],
+ null: false,
+ description: 'Last 24hr stats of the error.'
+ field :gitlab_commit, GraphQL::Types::String,
+ null: true,
+ description: 'GitLab commit SHA attributed to the Error based on the release version.'
+ field :gitlab_commit_path, GraphQL::Types::String,
+ null: true,
+ description: 'Path to the GitLab page for the GitLab commit attributed to the error.'
+ field :gitlab_issue_path, GraphQL::Types::String,
+ method: :gitlab_issue,
+ null: true,
+ description: 'URL of GitLab Issue.'
+ field :id, GraphQL::Types::ID,
+ null: false,
+ description: 'ID (global ID) of the error.'
+ field :integrated, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Error tracking backend.'
+ field :last_release_last_commit, GraphQL::Types::String,
+ null: true,
+ description: 'Commit the error was last seen.'
+ field :last_release_short_version, GraphQL::Types::String,
+ null: true,
+ description: 'Release short version the error was last seen.'
+ field :last_release_version, GraphQL::Types::String,
+ null: true,
+ description: 'Release version the error was last seen.'
field :last_seen, Types::TimeType,
null: false,
description: 'Timestamp when the error was last seen.'
field :message, GraphQL::Types::String,
null: true,
description: 'Sentry metadata message of the error.'
- field :culprit, GraphQL::Types::String,
- null: false,
- description: 'Culprit of the error.'
- field :external_base_url, GraphQL::Types::String,
- null: false,
- description: 'External Base URL of the Sentry Instance.'
- field :external_url, GraphQL::Types::String,
+ field :sentry_id, GraphQL::Types::String,
+ method: :id,
null: false,
- description: 'External URL of the error.'
+ description: 'ID (Sentry ID) of the error.'
field :sentry_project_id, GraphQL::Types::ID,
method: :project_id,
null: false,
@@ -68,40 +90,18 @@ module Types
field :status, Types::ErrorTracking::SentryErrorStatusEnum,
null: false,
description: 'Status of the error.'
- field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType],
- null: false,
- description: 'Last 24hr stats of the error.'
- field :first_release_last_commit, GraphQL::Types::String,
- null: true,
- description: 'Commit the error was first seen.'
- field :last_release_last_commit, GraphQL::Types::String,
- null: true,
- description: 'Commit the error was last seen.'
- field :first_release_short_version, GraphQL::Types::String,
- null: true,
- description: 'Release short version the error was first seen.'
- field :last_release_short_version, GraphQL::Types::String,
- null: true,
- description: 'Release short version the error was last seen.'
- field :first_release_version, GraphQL::Types::String,
- null: true,
- description: 'Release version the error was first seen.'
- field :last_release_version, GraphQL::Types::String,
- null: true,
- description: 'Release version the error was last seen.'
- field :gitlab_commit, GraphQL::Types::String,
- null: true,
- description: 'GitLab commit SHA attributed to the Error based on the release version.'
- field :gitlab_commit_path, GraphQL::Types::String,
- null: true,
- description: 'Path to the GitLab page for the GitLab commit attributed to the error.'
- field :gitlab_issue_path, GraphQL::Types::String,
- method: :gitlab_issue,
- null: true,
- description: 'URL of GitLab Issue.'
field :tags, Types::ErrorTracking::SentryErrorTagsType,
null: false,
description: 'Tags associated with the Sentry Error.'
+ field :title, GraphQL::Types::String,
+ null: false,
+ description: 'Title of the error.'
+ field :type, GraphQL::Types::String,
+ null: false,
+ description: 'Type of the error.'
+ field :user_count, GraphQL::Types::Int,
+ null: false,
+ description: 'Count of users affected by the error.'
end
end
end
diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb
index 2d8c3d3d326..9790560929b 100644
--- a/app/graphql/types/error_tracking/sentry_error_collection_type.rb
+++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb
@@ -8,15 +8,15 @@ module Types
authorize :read_sentry_issue
- field :errors,
- description: "Collection of Sentry Errors.",
- resolver: Resolvers::ErrorTracking::SentryErrorsResolver
field :detailed_error,
description: 'Detailed version of a Sentry error on the project.',
resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver
field :error_stack_trace,
description: 'Stack Trace of Sentry Error.',
resolver: Resolvers::ErrorTracking::SentryErrorStackTraceResolver
+ field :errors,
+ description: "Collection of Sentry Errors.",
+ resolver: Resolvers::ErrorTracking::SentryErrorsResolver
field :external_url,
GraphQL::Types::String,
null: true,
diff --git a/app/graphql/types/error_tracking/sentry_error_frequency_type.rb b/app/graphql/types/error_tracking/sentry_error_frequency_type.rb
index 49a1b1e0476..f67becb3774 100644
--- a/app/graphql/types/error_tracking/sentry_error_frequency_type.rb
+++ b/app/graphql/types/error_tracking/sentry_error_frequency_type.rb
@@ -6,12 +6,12 @@ module Types
class SentryErrorFrequencyType < ::Types::BaseObject
graphql_name 'SentryErrorFrequency'
- field :time, Types::TimeType,
- null: false,
- description: "Time the error frequency stats were recorded."
field :count, GraphQL::Types::Int,
null: false,
description: "Count of errors received since the previously recorded time."
+ field :time, Types::TimeType,
+ null: false,
+ description: "Time the error frequency stats were recorded."
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb
index ad31854b30c..d4b806c4e1e 100644
--- a/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb
+++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb
@@ -7,14 +7,14 @@ module Types
graphql_name 'SentryErrorStackTraceContext'
description 'An object context for a Sentry error stack trace'
- field :line,
- GraphQL::Types::Int,
- null: false,
- description: 'Line number of the context.'
field :code,
GraphQL::Types::String,
null: false,
description: 'Code number of the context.'
+ field :line,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Line number of the context.'
def line
object[0]
diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb
index e8f78004569..c33baa06052 100644
--- a/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb
+++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb
@@ -7,18 +7,18 @@ module Types
graphql_name 'SentryErrorStackTraceEntry'
description 'An object containing a stack trace entry for a Sentry error'
- field :function, GraphQL::Types::String,
+ field :col, GraphQL::Types::String,
null: true,
description: 'Function in which the Sentry error occurred.'
- field :col, GraphQL::Types::String,
+ field :file_name, GraphQL::Types::String,
+ null: true,
+ description: 'File in which the Sentry error occurred.'
+ field :function, GraphQL::Types::String,
null: true,
description: 'Function in which the Sentry error occurred.'
field :line, GraphQL::Types::String,
null: true,
description: 'Function in which the Sentry error occurred.'
- field :file_name, GraphQL::Types::String,
- null: true,
- description: 'File in which the Sentry error occurred.'
field :trace_context, [Types::ErrorTracking::SentryErrorStackTraceContextType],
null: true,
description: 'Context of the Sentry error.'
diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb
index dff52d77109..5c7aecf16ee 100644
--- a/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb
+++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb
@@ -8,12 +8,12 @@ module Types
authorize :read_sentry_issue
- field :issue_id, GraphQL::Types::String,
- null: false,
- description: 'ID of the Sentry error.'
field :date_received, GraphQL::Types::String,
null: false,
description: 'Time the stack trace was received by Sentry.'
+ field :issue_id, GraphQL::Types::String,
+ null: false,
+ description: 'ID of the Sentry error.'
field :stack_trace_entries, [Types::ErrorTracking::SentryErrorStackTraceEntryType],
null: false,
description: 'Stack trace entries for the Sentry error.'
diff --git a/app/graphql/types/error_tracking/sentry_error_type.rb b/app/graphql/types/error_tracking/sentry_error_type.rb
index aaa6cbfb28f..5f871155737 100644
--- a/app/graphql/types/error_tracking/sentry_error_type.rb
+++ b/app/graphql/types/error_tracking/sentry_error_type.rb
@@ -9,49 +9,34 @@ module Types
present_using SentryErrorPresenter
- field :id, GraphQL::Types::ID,
- null: false,
- description: 'ID (global ID) of the error.'
- field :sentry_id, GraphQL::Types::String,
- method: :id,
- null: false,
- description: 'ID (Sentry ID) of the error.'
- field :first_seen, Types::TimeType,
- null: false,
- description: 'Timestamp when the error was first seen.'
- field :last_seen, Types::TimeType,
- null: false,
- description: 'Timestamp when the error was last seen.'
- field :title, GraphQL::Types::String,
- null: false,
- description: 'Title of the error.'
- field :type, GraphQL::Types::String,
- null: false,
- description: 'Type of the error.'
- field :user_count, GraphQL::Types::Int,
- null: false,
- description: 'Count of users affected by the error.'
field :count, GraphQL::Types::Int,
null: false,
description: 'Count of occurrences.'
- field :message, GraphQL::Types::String,
- null: true,
- description: 'Sentry metadata message of the error.'
field :culprit, GraphQL::Types::String,
null: false,
description: 'Culprit of the error.'
field :external_url, GraphQL::Types::String,
null: false,
description: 'External URL of the error.'
- field :short_id, GraphQL::Types::String,
- null: false,
- description: 'Short ID (Sentry ID) of the error.'
- field :status, Types::ErrorTracking::SentryErrorStatusEnum,
+ field :first_seen, Types::TimeType,
null: false,
- description: 'Status of the error.'
+ description: 'Timestamp when the error was first seen.'
field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType],
null: false,
description: 'Last 24hr stats of the error.'
+ field :id, GraphQL::Types::ID,
+ null: false,
+ description: 'ID (global ID) of the error.'
+ field :last_seen, Types::TimeType,
+ null: false,
+ description: 'Timestamp when the error was last seen.'
+ field :message, GraphQL::Types::String,
+ null: true,
+ description: 'Sentry metadata message of the error.'
+ field :sentry_id, GraphQL::Types::String,
+ method: :id,
+ null: false,
+ description: 'ID (Sentry ID) of the error.'
field :sentry_project_id, GraphQL::Types::ID,
method: :project_id,
null: false,
@@ -64,6 +49,21 @@ module Types
method: :project_slug,
null: false,
description: 'Slug of the project affected by the error.'
+ field :short_id, GraphQL::Types::String,
+ null: false,
+ description: 'Short ID (Sentry ID) of the error.'
+ field :status, Types::ErrorTracking::SentryErrorStatusEnum,
+ null: false,
+ description: 'Status of the error.'
+ field :title, GraphQL::Types::String,
+ null: false,
+ description: 'Title of the error.'
+ field :type, GraphQL::Types::String,
+ null: false,
+ description: 'Type of the error.'
+ field :user_count, GraphQL::Types::Int,
+ null: false,
+ description: 'Count of users affected by the error.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/evidence_type.rb b/app/graphql/types/evidence_type.rb
index 33f46c712f1..ed644a4b2c6 100644
--- a/app/graphql/types/evidence_type.rb
+++ b/app/graphql/types/evidence_type.rb
@@ -9,13 +9,13 @@ module Types
present_using Releases::EvidencePresenter
+ field :collected_at, Types::TimeType, null: true,
+ description: 'Timestamp when the evidence was collected.'
+ field :filepath, GraphQL::Types::String, null: true,
+ description: 'URL from where the evidence can be downloaded.'
field :id, GraphQL::Types::ID, null: false,
description: 'ID of the evidence.'
field :sha, GraphQL::Types::String, null: true,
description: 'SHA1 ID of the evidence hash.'
- field :filepath, GraphQL::Types::String, null: true,
- description: 'URL from where the evidence can be downloaded.'
- field :collected_at, Types::TimeType, null: true,
- description: 'Timestamp when the evidence was collected.'
end
end
diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb
index c44c268b43f..4f92b5e8cc2 100644
--- a/app/graphql/types/global_id_type.rb
+++ b/app/graphql/types/global_id_type.rb
@@ -49,7 +49,11 @@ module Types
# Construct a restricted type, that can only be inhabited by an ID of
# a given model class.
def self.[](model_class)
- @id_types ||= {}
+ @id_types ||= {
+ # WorkItem has a special class as we want to allow IssueID
+ # on WorkItemID while we transition into work items
+ ::WorkItem => ::Types::WorkItemIdType
+ }
@id_types[model_class] ||= Class.new(self) do
model_name = model_class.name
diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb
index 26fefd51e08..2bbc0d34db6 100644
--- a/app/graphql/types/grafana_integration_type.rb
+++ b/app/graphql/types/grafana_integration_type.rb
@@ -6,14 +6,14 @@ module Types
authorize :admin_operations
- field :id, GraphQL::Types::ID, null: false,
- description: 'Internal ID of the Grafana integration.'
- field :grafana_url, GraphQL::Types::String, null: false,
- description: 'URL for the Grafana host for the Grafana integration.'
- field :enabled, GraphQL::Types::Boolean, null: false,
- description: 'Indicates whether Grafana integration is enabled.'
field :created_at, Types::TimeType, null: false,
description: 'Timestamp of the issue\'s creation.'
+ field :enabled, GraphQL::Types::Boolean, null: false,
+ description: 'Indicates whether Grafana integration is enabled.'
+ field :grafana_url, GraphQL::Types::String, null: false,
+ description: 'URL for the Grafana host for the Grafana integration.'
+ field :id, GraphQL::Types::ID, null: false,
+ description: 'Internal ID of the Grafana integration.'
field :updated_at, Types::TimeType, null: false,
description: 'Timestamp of the issue\'s last activity.'
end
diff --git a/app/graphql/types/group_member_type.rb b/app/graphql/types/group_member_type.rb
index d68abc11bba..18242f7b8b1 100644
--- a/app/graphql/types/group_member_type.rb
+++ b/app/graphql/types/group_member_type.rb
@@ -13,6 +13,10 @@ module Types
field :group, Types::GroupType, null: true,
description: 'Group that a User is a member of.'
+ field :notification_email,
+ resolver: Resolvers::GroupMembers::NotificationEmailResolver,
+ description: "Group notification email for User. Only availble for admins."
+
def group
Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find
end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 5f63aa20953..a94cd6fad20 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -209,8 +209,9 @@ module Types
field :work_item_types, Types::WorkItems::TypeType.connection_type,
resolver: Resolvers::WorkItems::TypesResolver,
- description: 'Work item types available to the group.',
- feature_flag: :work_items
+ description: 'Work item types available to the group.' \
+ ' Returns `null` if `work_items` feature flag is disabled.' \
+ ' This flag is disabled by default, because the feature is experimental and is subject to change without notice.'
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index ee57961ee4a..07450c38616 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -8,6 +8,7 @@ module Types
implements(Types::Notes::NoteableInterface)
implements(Types::CurrentUserTodos)
+ implements(Types::TodoableInterface)
authorize :read_issue
@@ -15,16 +16,16 @@ module Types
present_using IssuePresenter
+ field :description, GraphQL::Types::String, null: true,
+ description: 'Description of the issue.'
field :id, GraphQL::Types::ID, null: false,
description: "ID of the issue."
field :iid, GraphQL::Types::ID, null: false,
description: "Internal ID of the issue."
- field :title, GraphQL::Types::String, null: false,
- description: 'Title of the issue.'
- field :description, GraphQL::Types::String, null: true,
- description: 'Description of the issue.'
field :state, IssueStateEnum, null: false,
description: 'State of the issue.'
+ field :title, GraphQL::Types::String, null: false,
+ description: 'Title of the issue.'
field :reference, GraphQL::Types::String, null: false,
description: 'Internal reference of the issue. Returned in shortened format by default.',
@@ -47,52 +48,52 @@ module Types
field :milestone, Types::MilestoneType, null: true,
description: 'Milestone of the issue.'
- field :due_date, Types::TimeType, null: true,
- description: 'Due date of the issue.'
field :confidential, GraphQL::Types::Boolean, null: false,
description: 'Indicates the issue is confidential.'
+ field :discussion_locked, GraphQL::Types::Boolean, null: false,
+ description: 'Indicates discussion is locked on the issue.'
+ field :due_date, Types::TimeType, null: true,
+ description: 'Due date of the issue.'
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.'
- field :upvotes, GraphQL::Types::Int, null: false,
- description: 'Number of upvotes the issue has received.'
field :downvotes, GraphQL::Types::Int, null: false,
description: 'Number of downvotes the issue has received.'
field :merge_requests_count, GraphQL::Types::Int, null: false,
description: 'Number of merge requests that close the issue on merge.',
resolver: Resolvers::MergeRequestsCountResolver
- field :user_notes_count, GraphQL::Types::Int, null: false,
- description: 'Number of user notes of the issue.',
- resolver: Resolvers::UserNotesCountResolver
+ field :relative_position, GraphQL::Types::Int, null: true,
+ description: 'Relative position of the issue (used for positioning in epic tree and issue boards).'
+ field :upvotes, GraphQL::Types::Int, null: false,
+ description: 'Number of upvotes the issue has received.'
field :user_discussions_count, GraphQL::Types::Int, null: false,
description: 'Number of user discussions in the issue.',
resolver: Resolvers::UserDiscussionsCountResolver
+ field :user_notes_count, GraphQL::Types::Int, null: false,
+ description: 'Number of user notes of the issue.',
+ resolver: Resolvers::UserNotesCountResolver
field :web_path, GraphQL::Types::String, null: false, method: :issue_path,
description: 'Web path of the issue.'
field :web_url, GraphQL::Types::String, null: false,
description: 'Web URL of the issue.'
- field :relative_position, GraphQL::Types::Int, null: true,
- description: 'Relative position of the issue (used for positioning in epic tree and issue boards).'
- field :participants, Types::UserType.connection_type, null: true, complexity: 5,
- description: 'List of participants in the issue.',
- resolver: Resolvers::Users::ParticipantsResolver
field :emails_disabled, GraphQL::Types::Boolean, null: false,
method: :project_emails_disabled?,
description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.'
+ field :human_time_estimate, GraphQL::Types::String, null: true,
+ description: 'Human-readable time estimate of the issue.'
+ field :human_total_time_spent, GraphQL::Types::String, null: true,
+ description: 'Human-readable total time reported as spent on the issue.'
+ field :participants, Types::UserType.connection_type, null: true, complexity: 5,
+ description: 'List of participants in the issue.',
+ resolver: Resolvers::Users::ParticipantsResolver
field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5,
description: 'Indicates the currently logged in user is subscribed to the issue.'
field :time_estimate, GraphQL::Types::Int, null: false,
description: 'Time estimate of the issue.'
field :total_time_spent, GraphQL::Types::Int, null: false,
description: 'Total time reported as spent on the issue.'
- field :human_time_estimate, GraphQL::Types::String, null: true,
- description: 'Human-readable time estimate of the issue.'
- field :human_total_time_spent, GraphQL::Types::String, null: true,
- description: 'Human-readable total time reported as spent on the issue.'
field :closed_at, Types::TimeType, null: true,
description: 'Timestamp of when the issue was closed.'
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 73e090a4802..fc39efd2493 100644
--- a/app/graphql/types/issues/negated_issue_filter_input_type.rb
+++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb
@@ -5,6 +5,15 @@ module Types
class NegatedIssueFilterInputType < BaseInputObject
graphql_name 'NegatedIssueFilterInput'
+ argument :assignee_id, GraphQL::Types::String,
+ required: false,
+ description: 'ID of a user not assigned to the issues.'
+ argument :assignee_usernames, [GraphQL::Types::String],
+ required: false,
+ description: 'Usernames of users not assigned to the issue.'
+ argument :author_username, GraphQL::Types::String,
+ required: false,
+ description: "Username of a user who didn't author the issue."
argument :iids, [GraphQL::Types::String],
required: false,
description: 'List of IIDs of issues to exclude. For example, `[1, 2]`.'
@@ -14,24 +23,15 @@ module Types
argument :milestone_title, [GraphQL::Types::String],
required: false,
description: 'Milestone not applied to this issue.'
- argument :release_tag, [GraphQL::Types::String],
- required: false,
- description: "Release tag not associated with the issue's milestone. Ignored when parent is a group."
- 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.'
- argument :assignee_id, GraphQL::Types::String,
- required: false,
- description: 'ID of a user not assigned to the issues.'
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.'
+ argument :release_tag, [GraphQL::Types::String],
+ required: false,
+ description: "Release tag not associated with the issue's milestone. Ignored when parent is a group."
argument :types, [Types::IssueTypeEnum],
as: :issue_types,
description: 'Filters out issues by the given issue types.',
diff --git a/app/graphql/types/jira_import_type.rb b/app/graphql/types/jira_import_type.rb
index 0cdfc178350..8477f0b97f0 100644
--- a/app/graphql/types/jira_import_type.rb
+++ b/app/graphql/types/jira_import_type.rb
@@ -8,16 +8,16 @@ module Types
field :created_at, Types::TimeType, null: true,
description: 'Timestamp of when the Jira import was created.'
+ field :failed_to_import_count, GraphQL::Types::Int, null: false,
+ description: 'Count of issues that failed to import.'
+ field :imported_issues_count, GraphQL::Types::Int, null: false,
+ description: 'Count of issues that were successfully imported.'
+ field :jira_project_key, GraphQL::Types::String, null: false,
+ description: 'Project key for the imported Jira project.'
field :scheduled_at, Types::TimeType, null: true,
description: 'Timestamp of when the Jira import was scheduled.'
field :scheduled_by, Types::UserType, null: true,
description: 'User that started the Jira import.'
- field :jira_project_key, GraphQL::Types::String, null: false,
- description: 'Project key for the imported Jira project.'
- field :imported_issues_count, GraphQL::Types::Int, null: false,
- description: 'Count of issues that were successfully imported.'
- field :failed_to_import_count, GraphQL::Types::Int, null: false,
- description: 'Count of issues that failed to import.'
field :total_issue_count, GraphQL::Types::Int, null: false,
description: 'Total count of issues that were attempted to import.'
end
diff --git a/app/graphql/types/jira_user_type.rb b/app/graphql/types/jira_user_type.rb
index 6e1c349726c..aba05385ece 100644
--- a/app/graphql/types/jira_user_type.rb
+++ b/app/graphql/types/jira_user_type.rb
@@ -6,18 +6,18 @@ module Types
class JiraUserType < BaseObject
graphql_name 'JiraUser'
+ field :gitlab_id, GraphQL::Types::Int, null: true,
+ description: 'ID of the matched GitLab user.'
+ field :gitlab_name, GraphQL::Types::String, null: true,
+ description: 'Name of the matched GitLab user.'
+ field :gitlab_username, GraphQL::Types::String, null: true,
+ description: 'Username of the matched GitLab user.'
field :jira_account_id, GraphQL::Types::String, null: false,
description: 'Account ID of the Jira user.'
field :jira_display_name, GraphQL::Types::String, null: false,
description: 'Display name of the Jira user.'
field :jira_email, GraphQL::Types::String, null: true,
description: 'Email of the Jira user, returned only for users with public emails.'
- field :gitlab_id, GraphQL::Types::Int, null: true,
- description: 'ID of the matched GitLab user.'
- field :gitlab_username, GraphQL::Types::String, null: true,
- description: 'Username of the matched GitLab user.'
- field :gitlab_name, GraphQL::Types::String, null: true,
- description: 'Name of the matched GitLab user.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/jira_users_mapping_input_type.rb b/app/graphql/types/jira_users_mapping_input_type.rb
index 37fd05370c0..4df2e27b45a 100644
--- a/app/graphql/types/jira_users_mapping_input_type.rb
+++ b/app/graphql/types/jira_users_mapping_input_type.rb
@@ -4,13 +4,13 @@ module Types
class JiraUsersMappingInputType < BaseInputObject
graphql_name 'JiraUsersMappingInputType'
- argument :jira_account_id,
- GraphQL::Types::String,
- required: true,
- description: 'Jira account ID of the user.'
argument :gitlab_id,
GraphQL::Types::Int,
required: false,
description: 'ID of the GitLab user.'
+ argument :jira_account_id,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Jira account ID of the user.'
end
end
diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb
index 5a10bcfee74..b5b3e20bcbc 100644
--- a/app/graphql/types/label_type.rb
+++ b/app/graphql/types/label_type.rb
@@ -8,18 +8,18 @@ module Types
authorize :read_label
- field :id, GraphQL::Types::ID, null: false,
- description: 'Label ID.'
- field :description, GraphQL::Types::String, null: true,
- description: 'Description of the label (Markdown rendered as HTML for caching).'
- field :title, GraphQL::Types::String, null: false,
- description: 'Content of the label.'
field :color, GraphQL::Types::String, null: false,
description: 'Background color of the label.'
- field :text_color, GraphQL::Types::String, null: false,
- description: 'Text color of the label.'
field :created_at, Types::TimeType, null: false,
description: 'When this label was created.'
+ field :description, GraphQL::Types::String, null: true,
+ description: 'Description of the label (Markdown rendered as HTML for caching).'
+ field :id, GraphQL::Types::ID, null: false,
+ description: 'Label ID.'
+ field :text_color, GraphQL::Types::String, null: false,
+ description: 'Text color of the label.'
+ field :title, GraphQL::Types::String, null: false,
+ description: 'Content of the label.'
field :updated_at, Types::TimeType, null: false,
description: 'When this label was last updated.'
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index ea05671c79c..af198d03c3f 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -8,6 +8,7 @@ module Types
implements(Types::Notes::NoteableInterface)
implements(Types::CurrentUserTodos)
+ implements(Types::TodoableInterface)
authorize :read_merge_request
@@ -15,94 +16,96 @@ module Types
present_using MergeRequestPresenter
+ field :created_at, Types::TimeType, null: false,
+ description: 'Timestamp of when the merge request was created.'
+ field :description, GraphQL::Types::String, null: true,
+ description: 'Description of the merge request (Markdown rendered as HTML for caching).'
+ field :diff_head_sha, GraphQL::Types::String, null: true,
+ description: 'Diff head SHA of the merge request.'
+ field :diff_refs, Types::DiffRefsType, null: true,
+ description: 'References of the base SHA, the head SHA, and the start SHA for this 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: 'Specific file path.'
+ end
+ field :draft, GraphQL::Types::Boolean, method: :draft?, null: false,
+ description: 'Indicates if the merge request is a draft.'
field :id, GraphQL::Types::ID, null: false,
description: 'ID of the merge request.'
field :iid, GraphQL::Types::String, null: false,
description: 'Internal ID of the merge request.'
- field :title, GraphQL::Types::String, null: false,
- description: 'Title of the merge request.'
- field :description, GraphQL::Types::String, null: true,
- description: 'Description of the merge request (Markdown rendered as HTML for caching).'
- field :state, MergeRequestStateEnum, null: false,
- description: 'State of the merge request.'
- field :created_at, Types::TimeType, null: false,
- description: 'Timestamp of when the merge request was created.'
- field :updated_at, Types::TimeType, null: false,
- description: 'Timestamp of when the merge request was last updated.'
+ field :merge_when_pipeline_succeeds, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).'
field :merged_at, Types::TimeType, null: true, complexity: 5,
description: 'Timestamp of when the merge request was merged, null if not merged.'
- field :source_project, Types::ProjectType, null: true,
- description: 'Source project of the merge request.'
- field :target_project, Types::ProjectType, null: false,
- description: 'Target project of the merge request.'
- field :diff_refs, Types::DiffRefsType, null: true,
- description: 'References of the base SHA, the head SHA, and the start SHA for this merge request.'
field :project, Types::ProjectType, null: false,
description: 'Alias for target_project.'
field :project_id, GraphQL::Types::Int, null: false, method: :target_project_id,
description: 'ID of the merge request project.'
- field :source_project_id, GraphQL::Types::Int, null: true,
- description: 'ID of the merge request source project.'
- field :target_project_id, GraphQL::Types::Int, null: false,
- description: 'ID of the merge request target project.'
field :source_branch, GraphQL::Types::String, null: false,
description: 'Source branch of the merge request.'
field :source_branch_protected, GraphQL::Types::Boolean, null: false, calls_gitaly: true,
description: 'Indicates if the source branch is protected.'
+ field :source_project, Types::ProjectType, null: true,
+ description: 'Source project of the merge request.'
+ field :source_project_id, GraphQL::Types::Int, null: true,
+ description: 'ID of the merge request source project.'
+ field :state, MergeRequestStateEnum, null: false,
+ description: 'State of the merge request.'
field :target_branch, GraphQL::Types::String, null: false,
description: 'Target branch of the merge request.'
- field :draft, GraphQL::Types::Boolean, method: :draft?, null: false,
- description: 'Indicates if the merge request is a draft.'
- field :merge_when_pipeline_succeeds, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).'
- field :diff_head_sha, GraphQL::Types::String, null: true,
- 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: 'Specific file path.'
- end
+ field :target_project, Types::ProjectType, null: false,
+ description: 'Target project of the merge request.'
+ field :target_project_id, GraphQL::Types::Int, null: false,
+ description: 'ID of the merge request target project.'
+ field :title, GraphQL::Types::String, null: false,
+ description: 'Title of the merge request.'
+ field :updated_at, Types::TimeType, null: false,
+ description: 'Timestamp of when the merge request was last updated.'
+ field :allow_collaboration, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates if members of the target project can push to the fork.'
+ field :default_merge_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true,
+ description: 'Default merge commit message of the merge request.'
+ field :default_merge_commit_message_with_description, GraphQL::Types::String, null: true,
+ description: 'Default merge commit message of the merge request with description. Will have the same value as `defaultMergeCommitMessage` when project has `mergeCommitTemplate` set.',
+ deprecated: { reason: 'Define merge commit template in project and use `defaultMergeCommitMessage`', milestone: '14.5' }
+ field :default_squash_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true,
+ description: 'Default squash commit message of the merge request.'
field :diff_stats_summary, Types::DiffStatsSummaryType, null: true, calls_gitaly: true,
description: 'Summary of which files were changed in this merge request.'
- field :merge_commit_sha, GraphQL::Types::String, null: true,
- description: 'SHA of the merge request commit (set once merged).'
- field :user_notes_count, GraphQL::Types::Int, null: true,
- description: 'User notes count of the merge request.',
- resolver: Resolvers::UserNotesCountResolver
- field :user_discussions_count, GraphQL::Types::Int, null: true,
- description: 'Number of user discussions in the merge request.',
- resolver: Resolvers::UserDiscussionsCountResolver
- field :should_remove_source_branch, GraphQL::Types::Boolean, method: :should_remove_source_branch?, null: true,
- description: 'Indicates if the source branch of the merge request will be deleted after merge.'
+ field :diverged_from_target_branch, GraphQL::Types::Boolean,
+ null: false, calls_gitaly: true,
+ method: :diverged_from_target_branch?,
+ description: 'Indicates if the source branch is behind the target branch.'
+ field :downvotes, GraphQL::Types::Int, null: false,
+ description: 'Number of downvotes for the merge request.'
field :force_remove_source_branch, GraphQL::Types::Boolean, method: :force_remove_source_branch?, null: true,
description: 'Indicates if the project settings will lead to source branch deletion after merge.'
+ field :in_progress_merge_commit_sha, GraphQL::Types::String, null: true,
+ description: 'Commit SHA of the merge request if merge is in progress.'
+ field :merge_commit_sha, GraphQL::Types::String, null: true,
+ description: 'SHA of the merge request commit (set once merged).'
+ field :merge_error, GraphQL::Types::String, null: true,
+ description: 'Error message due to a merge error.'
+ field :merge_ongoing, GraphQL::Types::Boolean, method: :merge_ongoing?, null: false,
+ description: 'Indicates if a merge is currently occurring.'
field :merge_status, GraphQL::Types::String, method: :public_merge_status, null: true,
description: 'Status of the merge request.',
deprecated: { reason: :renamed, replacement: 'MergeRequest.mergeStatusEnum', milestone: '14.0' }
field :merge_status_enum, ::Types::MergeRequests::MergeStatusEnum,
method: :public_merge_status, null: true,
description: 'Merge status of the merge request.'
- field :in_progress_merge_commit_sha, GraphQL::Types::String, null: true,
- description: 'Commit SHA of the merge request if merge is in progress.'
- field :merge_error, GraphQL::Types::String, null: true,
- description: 'Error message due to a merge error.'
- field :allow_collaboration, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if members of the target project can push to the fork.'
- field :should_be_rebased, GraphQL::Types::Boolean, method: :should_be_rebased?, null: false, calls_gitaly: true,
- description: 'Indicates if the merge request will be rebased.'
+ field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.'
field :rebase_commit_sha, GraphQL::Types::String, null: true,
description: 'Rebase commit SHA of the merge request.'
field :rebase_in_progress, GraphQL::Types::Boolean, method: :rebase_in_progress?, null: false, calls_gitaly: true,
description: 'Indicates if there is a rebase currently in progress for the merge request.'
- field :default_merge_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'Default merge commit message of the merge request.'
- field :default_merge_commit_message_with_description, GraphQL::Types::String, null: true,
- description: 'Default merge commit message of the merge request with description. Will have the same value as `defaultMergeCommitMessage` when project has `mergeCommitTemplate` set.',
- deprecated: { reason: 'Define merge commit template in project and use `defaultMergeCommitMessage`', milestone: '14.5' }
- field :default_squash_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'Default squash commit message of the merge request.'
- field :merge_ongoing, GraphQL::Types::Boolean, method: :merge_ongoing?, null: false,
- description: 'Indicates if a merge is currently occurring.'
+ field :should_be_rebased, GraphQL::Types::Boolean, method: :should_be_rebased?, null: false, calls_gitaly: true,
+ description: 'Indicates if the merge request will be rebased.'
+ field :should_remove_source_branch, GraphQL::Types::Boolean, method: :should_remove_source_branch?, null: true,
+ description: 'Indicates if the source branch of the merge request will be deleted after merge.'
field :source_branch_exists, GraphQL::Types::Boolean,
null: false, calls_gitaly: true,
method: :source_branch_exists?,
@@ -111,18 +114,16 @@ module Types
null: false, calls_gitaly: true,
method: :target_branch_exists?,
description: 'Indicates if the target branch of the merge request exists.'
- field :diverged_from_target_branch, GraphQL::Types::Boolean,
- null: false, calls_gitaly: true,
- method: :diverged_from_target_branch?,
- description: 'Indicates if the source branch is behind the target branch.'
- field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.'
- field :web_url, GraphQL::Types::String, null: true,
- description: 'Web URL of the merge request.'
field :upvotes, GraphQL::Types::Int, null: false,
description: 'Number of upvotes for the merge request.'
- field :downvotes, GraphQL::Types::Int, null: false,
- description: 'Number of downvotes for the merge request.'
+ field :user_discussions_count, GraphQL::Types::Int, null: true,
+ description: 'Number of user discussions in the merge request.',
+ resolver: Resolvers::UserDiscussionsCountResolver
+ field :user_notes_count, GraphQL::Types::Int, null: true,
+ description: 'User notes count of the merge request.',
+ resolver: Resolvers::UserNotesCountResolver
+ field :web_url, GraphQL::Types::String, null: true,
+ description: 'Web URL of the merge request.'
field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline,
description: 'Pipeline running on the branch HEAD of the merge request.'
@@ -131,84 +132,82 @@ module Types
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: 'Milestone of the merge request.'
field :assignees,
type: Types::MergeRequests::AssigneeType.connection_type,
null: true,
complexity: 5,
description: 'Assignees of the merge request.'
- field :reviewers,
- type: Types::MergeRequests::ReviewerType.connection_type,
- null: true,
- complexity: 5,
- description: 'Users from whom a review has been requested.'
- field :author, Types::UserType, null: true,
+ field :author, Types::MergeRequests::AuthorType, null: true,
description: 'User who created this merge request.'
- field :participants, Types::UserType.connection_type, null: true, complexity: 15,
- description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.',
- resolver: Resolvers::Users::ParticipantsResolver
- field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5,
- description: 'Indicates if the currently logged in user is subscribed to this merge request.'
- field :labels, Types::LabelType.connection_type, null: true, complexity: 5,
- description: 'Labels of the merge request.'
field :discussion_locked, GraphQL::Types::Boolean,
description: 'Indicates if comments on the merge request are locked to members only.',
null: false
- field :time_estimate, GraphQL::Types::Int, null: false,
- description: 'Time estimate of the merge request.'
- field :total_time_spent, GraphQL::Types::Int, null: false,
- description: 'Total time reported as spent on the merge request.'
field :human_time_estimate, GraphQL::Types::String, null: true,
description: 'Human-readable time estimate of the merge request.'
field :human_total_time_spent, GraphQL::Types::String, null: true,
description: 'Human-readable total time reported as spent on the merge request.'
+ field :labels, Types::LabelType.connection_type, null: true, complexity: 5,
+ description: 'Labels of the merge request.'
+ field :milestone, Types::MilestoneType, null: true,
+ description: 'Milestone of the merge request.'
+ field :participants, Types::MergeRequests::ParticipantType.connection_type, null: true, complexity: 15,
+ description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.',
+ resolver: Resolvers::Users::ParticipantsResolver
field :reference, GraphQL::Types::String, null: false, method: :to_reference,
description: 'Internal reference of the merge request. Returned in shortened format by default.' do
argument :full, GraphQL::Types::Boolean, required: false, default_value: false,
description: 'Boolean option specifying whether the reference should be returned in full.'
end
- field :task_completion_status, Types::TaskCompletionStatus, null: false,
- description: Types::TaskCompletionStatus.description
+ field :auto_merge_enabled, GraphQL::Types::Boolean, null: false,
+ description: 'Indicates if auto merge is enabled for the merge request.'
field :commit_count, GraphQL::Types::Int, null: true, method: :commits_count,
description: 'Number of commits in the merge request.'
field :conflicts, GraphQL::Types::Boolean, null: false, method: :cannot_be_merged?,
description: 'Indicates if the merge request has conflicts.'
- field :auto_merge_enabled, GraphQL::Types::Boolean, null: false,
- description: 'Indicates if auto merge is enabled for the merge request.'
+ field :reviewers,
+ type: Types::MergeRequests::ReviewerType.connection_type,
+ null: true,
+ complexity: 5,
+ description: 'Users from whom a review has been requested.'
+ field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5,
+ description: 'Indicates if the currently logged in user is subscribed to this merge request.'
+ field :task_completion_status, Types::TaskCompletionStatus, null: false,
+ description: Types::TaskCompletionStatus.description
+ field :time_estimate, GraphQL::Types::Int, null: false,
+ description: 'Time estimate of the merge request.'
+ field :total_time_spent, GraphQL::Types::Int, null: false,
+ description: 'Total time reported as spent on the merge request.'
field :approved_by, Types::UserType.connection_type, null: true,
- description: 'Users who approved the merge request.'
- field :squash_on_merge, GraphQL::Types::Boolean, null: false, method: :squash_on_merge?,
- description: 'Indicates if squash on merge is enabled.'
- field :squash, GraphQL::Types::Boolean, null: false,
- description: 'Indicates if squash on merge is enabled.'
+ description: 'Users who approved the merge request.', method: :approved_by_users
+ field :auto_merge_strategy, GraphQL::Types::String, null: true,
+ description: 'Selected auto merge strategy.'
field :available_auto_merge_strategies, [GraphQL::Types::String], null: true, calls_gitaly: true,
description: 'Array of available auto merge strategies.'
- field :has_ci, GraphQL::Types::Boolean, null: false, method: :has_ci?,
- description: 'Indicates if the merge request has CI.'
- field :mergeable, GraphQL::Types::Boolean, null: false, method: :mergeable?, calls_gitaly: true,
- description: 'Indicates if the merge request is mergeable.'
field :commits, Types::CommitType.connection_type, null: true,
calls_gitaly: true, description: 'Merge request commits.'
+ field :committers, Types::UserType.connection_type, null: true, complexity: 5,
+ calls_gitaly: true, description: 'Users who have added commits to the merge request.'
field :commits_without_merge_commits, Types::CommitType.connection_type, null: true,
calls_gitaly: true, description: 'Merge request commits excluding merge commits.'
- field :security_auto_fix, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the merge request is created by @GitLab-Security-Bot.'
- field :auto_merge_strategy, GraphQL::Types::String, null: true,
- description: 'Selected auto merge strategy.'
+ field :has_ci, GraphQL::Types::Boolean, null: false, method: :has_ci?,
+ description: 'Indicates if the merge request has CI.'
field :merge_user, Types::UserType, null: true,
description: 'User who merged this merge request or set it to merge when pipeline succeeds.'
+ field :mergeable, GraphQL::Types::Boolean, null: false, method: :mergeable?, calls_gitaly: true,
+ description: 'Indicates if the merge request is mergeable.'
+ field :security_auto_fix, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates if the merge request is created by @GitLab-Security-Bot.'
+ field :squash, GraphQL::Types::Boolean, null: false,
+ description: 'Indicates if squash on merge is enabled.'
+ field :squash_on_merge, GraphQL::Types::Boolean, null: false, method: :squash_on_merge?,
+ description: 'Indicates if squash on merge is enabled.'
field :timelogs, Types::TimelogType.connection_type, null: false,
description: 'Timelogs on the merge request.'
markdown_field :title_html, null: true
markdown_field :description_html, null: true
- def approved_by
- object.approved_by_users
- end
-
def user_notes_count
BatchLoader::GraphQL.for(object.id).batch(key: :merge_request_user_notes_count) do |ids, loader, args|
counts = Note.count_for_collection(ids, 'MergeRequest').index_by(&:noteable_id)
@@ -279,10 +278,6 @@ module Types
object.author == User.security_bot
end
- def reviewers
- object.reviewers
- end
-
def merge_user
object.metrics&.merged_by || object.merge_user
end
diff --git a/app/graphql/types/merge_requests/assignee_type.rb b/app/graphql/types/merge_requests/assignee_type.rb
index 24321d057a3..a0ba74597ba 100644
--- a/app/graphql/types/merge_requests/assignee_type.rb
+++ b/app/graphql/types/merge_requests/assignee_type.rb
@@ -6,7 +6,6 @@ module Types
graphql_name 'MergeRequestAssignee'
description 'A user assigned to a merge request.'
- include FindClosest
include ::Types::MergeRequests::InteractsWithMergeRequest
authorize :read_user
diff --git a/app/graphql/types/merge_requests/author_type.rb b/app/graphql/types/merge_requests/author_type.rb
new file mode 100644
index 00000000000..56ad3190547
--- /dev/null
+++ b/app/graphql/types/merge_requests/author_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module MergeRequests
+ class AuthorType < ::Types::UserType
+ graphql_name 'MergeRequestAuthor'
+ description 'The author of the merge request.'
+
+ include ::Types::MergeRequests::InteractsWithMergeRequest
+
+ authorize :read_user
+ end
+ end
+end
diff --git a/app/graphql/types/merge_requests/participant_type.rb b/app/graphql/types/merge_requests/participant_type.rb
new file mode 100644
index 00000000000..86d627097b2
--- /dev/null
+++ b/app/graphql/types/merge_requests/participant_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module MergeRequests
+ class ParticipantType < ::Types::UserType
+ graphql_name 'MergeRequestParticipant'
+ description 'A user participating in a merge request.'
+
+ include ::Types::MergeRequests::InteractsWithMergeRequest
+
+ authorize :read_user
+ end
+ end
+end
diff --git a/app/graphql/types/merge_requests/reviewer_type.rb b/app/graphql/types/merge_requests/reviewer_type.rb
index 11f7ceaf461..e5bc5812816 100644
--- a/app/graphql/types/merge_requests/reviewer_type.rb
+++ b/app/graphql/types/merge_requests/reviewer_type.rb
@@ -6,7 +6,6 @@ module Types
graphql_name 'MergeRequestReviewer'
description 'A user assigned to a merge request as a reviewer.'
- include FindClosest
include ::Types::MergeRequests::InteractsWithMergeRequest
authorize :read_user
diff --git a/app/graphql/types/metadata/kas_type.rb b/app/graphql/types/metadata/kas_type.rb
index 54a8a6ec40d..6a8d54b6c7d 100644
--- a/app/graphql/types/metadata/kas_type.rb
+++ b/app/graphql/types/metadata/kas_type.rb
@@ -9,10 +9,10 @@ module Types
field :enabled, GraphQL::Types::Boolean, null: false,
description: 'Indicates whether the Kubernetes Agent Server is enabled.'
- field :version, GraphQL::Types::String, null: true,
- description: 'KAS version.'
field :external_url, GraphQL::Types::String, null: true,
description: 'URL used by the Agents to communicate with KAS.'
+ field :version, GraphQL::Types::String, null: true,
+ description: 'KAS version.'
end
end
end
diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb
index ed1e697711d..6fb141a50c9 100644
--- a/app/graphql/types/metadata_type.rb
+++ b/app/graphql/types/metadata_type.rb
@@ -6,11 +6,11 @@ module Types
authorize :read_instance_metadata
- field :version, GraphQL::Types::String, null: false,
- description: 'Version.'
- field :revision, GraphQL::Types::String, null: false,
- description: 'Revision.'
field :kas, ::Types::Metadata::KasType, null: false,
description: 'Metadata about KAS.'
+ field :revision, GraphQL::Types::String, null: false,
+ description: 'Revision.'
+ field :version, GraphQL::Types::String, null: false,
+ description: 'Version.'
end
end
diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb
index 0c787476f54..0621cf4d674 100644
--- a/app/graphql/types/metrics/dashboards/annotation_type.rb
+++ b/app/graphql/types/metrics/dashboards/annotation_type.rb
@@ -14,17 +14,14 @@ module Types
description: 'ID of the annotation.'
field :panel_id, GraphQL::Types::String, null: true,
- description: 'ID of a dashboard panel to which the annotation should be scoped.'
+ description: 'ID of a dashboard panel to which the annotation should be scoped.',
+ method: :panel_xid
field :starting_at, Types::TimeType, null: true,
description: 'Timestamp marking start of annotated time span.'
field :ending_at, Types::TimeType, null: true,
description: 'Timestamp marking end of annotated time span.'
-
- def panel_id
- object.panel_xid
- end
end
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 3c735231595..e6072820eea 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -126,8 +126,11 @@ module Types
mount_mutation Mutations::Packages::DestroyFile
mount_mutation Mutations::Echo
mount_mutation Mutations::WorkItems::Create
+ mount_mutation Mutations::WorkItems::CreateFromTask
mount_mutation Mutations::WorkItems::Delete
mount_mutation Mutations::WorkItems::Update
+ mount_mutation Mutations::SavedReplies::Create
+ mount_mutation Mutations::SavedReplies::Update
end
end
diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb
index d573cc9ded5..cb546bbf3ec 100644
--- a/app/graphql/types/namespace/package_settings_type.rb
+++ b/app/graphql/types/namespace/package_settings_type.rb
@@ -8,9 +8,9 @@ module Types
authorize :read_package_settings
- field :maven_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate Maven packages are allowed for this namespace.'
- field :maven_duplicate_exception_regex, Types::UntrustedRegexp, null: true, description: 'When maven_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.'
- field :generic_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate generic packages are allowed for this namespace.'
field :generic_duplicate_exception_regex, Types::UntrustedRegexp, null: true, description: 'When generic_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.'
+ field :generic_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate generic packages are allowed for this namespace.'
+ field :maven_duplicate_exception_regex, Types::UntrustedRegexp, null: true, description: 'When maven_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.'
+ field :maven_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate Maven packages are allowed for this namespace.'
end
end
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
index ba90fb06cb2..de6a078c6ef 100644
--- a/app/graphql/types/namespace_type.rb
+++ b/app/graphql/types/namespace_type.rb
@@ -9,24 +9,28 @@ module Types
field :id, GraphQL::Types::ID, null: false,
description: 'ID of the namespace.'
- field :name, GraphQL::Types::String, null: false,
- description: 'Name of the namespace.'
- field :path, GraphQL::Types::String, null: false,
- description: 'Path of the namespace.'
field :full_name, GraphQL::Types::String, null: false,
description: 'Full name of the namespace.'
field :full_path, GraphQL::Types::ID, null: false,
description: 'Full path of the namespace.'
+ field :name, GraphQL::Types::String, null: false,
+ description: 'Name of the namespace.'
+ field :path, GraphQL::Types::String, null: false,
+ description: 'Path of the namespace.'
+
+ field :cross_project_pipeline_available, GraphQL::Types::Boolean, null: false,
+ resolver_method: :cross_project_pipeline_available?,
+ description: 'Indicates if the cross_project_pipeline feature is available for the namespace.'
field :description, GraphQL::Types::String, null: true,
description: 'Description of the namespace.'
- field :visibility, GraphQL::Types::String, null: true,
- description: 'Visibility of the namespace.'
field :lfs_enabled, GraphQL::Types::Boolean, null: true, method: :lfs_enabled?,
description: 'Indicates if Large File Storage (LFS) is enabled for namespace.'
field :request_access_enabled, GraphQL::Types::Boolean, null: true,
description: 'Indicates if users can request access to namespace.'
+ field :visibility, GraphQL::Types::String, null: true,
+ description: 'Visibility of the namespace.'
field :root_storage_statistics, Types::RootStorageStatisticsType,
null: true,
@@ -48,6 +52,10 @@ module Types
markdown_field :description_html, null: true
+ def cross_project_pipeline_available?
+ object.licensed_feature_available?(:cross_project_pipelines)
+ end
+
def root_storage_statistics
Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find
end
diff --git a/app/graphql/types/notes/diff_image_position_input_type.rb b/app/graphql/types/notes/diff_image_position_input_type.rb
index d56c67bbec8..d535dea2e07 100644
--- a/app/graphql/types/notes/diff_image_position_input_type.rb
+++ b/app/graphql/types/notes/diff_image_position_input_type.rb
@@ -5,14 +5,14 @@ module Types
class DiffImagePositionInputType < DiffPositionBaseInputType
graphql_name 'DiffImagePositionInput'
+ argument :height, GraphQL::Types::Int, required: true,
+ description: copy_field_description(Types::Notes::DiffPositionType, :height)
+ argument :width, GraphQL::Types::Int, required: true,
+ description: copy_field_description(Types::Notes::DiffPositionType, :width)
argument :x, GraphQL::Types::Int, required: true,
description: copy_field_description(Types::Notes::DiffPositionType, :x)
argument :y, GraphQL::Types::Int, required: true,
description: copy_field_description(Types::Notes::DiffPositionType, :y)
- argument :width, GraphQL::Types::Int, required: true,
- description: copy_field_description(Types::Notes::DiffPositionType, :width)
- argument :height, GraphQL::Types::Int, required: true,
- description: copy_field_description(Types::Notes::DiffPositionType, :height)
end
end
end
diff --git a/app/graphql/types/notes/diff_position_base_input_type.rb b/app/graphql/types/notes/diff_position_base_input_type.rb
index e773fbbc8a1..2780dbab573 100644
--- a/app/graphql/types/notes/diff_position_base_input_type.rb
+++ b/app/graphql/types/notes/diff_position_base_input_type.rb
@@ -3,10 +3,10 @@
module Types
module Notes
class DiffPositionBaseInputType < BaseInputObject
+ argument :base_sha, GraphQL::Types::String, required: false,
+ description: copy_field_description(Types::DiffRefsType, :base_sha)
argument :head_sha, GraphQL::Types::String, required: true,
description: copy_field_description(Types::DiffRefsType, :head_sha)
- argument :base_sha, GraphQL::Types::String, required: false,
- description: copy_field_description(Types::DiffRefsType, :base_sha)
argument :start_sha, GraphQL::Types::String, required: true,
description: copy_field_description(Types::DiffRefsType, :start_sha)
diff --git a/app/graphql/types/notes/diff_position_input_type.rb b/app/graphql/types/notes/diff_position_input_type.rb
index 18ce6672d14..ccde4188f29 100644
--- a/app/graphql/types/notes/diff_position_input_type.rb
+++ b/app/graphql/types/notes/diff_position_input_type.rb
@@ -5,10 +5,10 @@ module Types
class DiffPositionInputType < DiffPositionBaseInputType
graphql_name 'DiffPositionInput'
- argument :old_line, GraphQL::Types::Int, required: false,
- description: copy_field_description(Types::Notes::DiffPositionType, :old_line)
argument :new_line, GraphQL::Types::Int, required: false,
- description: copy_field_description(Types::Notes::DiffPositionType, :new_line)
+ description: "#{copy_field_description(Types::Notes::DiffPositionType, :new_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field."
+ argument :old_line, GraphQL::Types::Int, required: false,
+ description: "#{copy_field_description(Types::Notes::DiffPositionType, :old_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field."
end
end
end
diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb
index 9c756d56b97..531bd0edac0 100644
--- a/app/graphql/types/notes/diff_position_type.rb
+++ b/app/graphql/types/notes/diff_position_type.rb
@@ -12,28 +12,28 @@ module Types
field :file_path, GraphQL::Types::String, null: false,
description: 'Path of the file that was changed.'
- field :old_path, GraphQL::Types::String, null: true,
- description: 'Path of the file on the start SHA.'
field :new_path, GraphQL::Types::String, null: true,
description: 'Path of the file on the HEAD SHA.'
+ field :old_path, GraphQL::Types::String, null: true,
+ description: 'Path of the file on the start SHA.'
field :position_type, Types::Notes::PositionTypeEnum, null: false,
description: 'Type of file the position refers to.'
# Fields for text positions
- field :old_line, GraphQL::Types::Int, null: true,
- description: 'Line on start SHA that was changed.'
field :new_line, GraphQL::Types::Int, null: true,
description: 'Line on HEAD SHA that was changed.'
+ field :old_line, GraphQL::Types::Int, null: true,
+ description: 'Line on start SHA that was changed.'
# Fields for image positions
+ field :height, GraphQL::Types::Int, null: true,
+ description: 'Total height of the image.'
+ field :width, GraphQL::Types::Int, null: true,
+ description: 'Total width of the image.'
field :x, GraphQL::Types::Int, null: true,
description: 'X position of the note.'
field :y, GraphQL::Types::Int, null: true,
description: 'Y position of the note.'
- field :width, GraphQL::Types::Int, null: true,
- description: 'Total width of the image.'
- field :height, GraphQL::Types::Int, null: true,
- description: 'Total height of the image.'
def old_line
object.old_line if object.on_text?
diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb
index ffe61c9ff88..89778b2a99a 100644
--- a/app/graphql/types/notes/discussion_type.rb
+++ b/app/graphql/types/notes/discussion_type.rb
@@ -11,16 +11,16 @@ module Types
implements(Types::ResolvableInterface)
- field :id, DiscussionID, null: false,
- description: "ID of this discussion."
- field :reply_id, DiscussionID, null: false,
- description: 'ID used to reply to this discussion.'
field :created_at, Types::TimeType, null: false,
description: "Timestamp of the discussion's creation."
- field :notes, Types::Notes::NoteType.connection_type, null: false,
- description: 'All notes in the discussion.'
+ field :id, DiscussionID, null: false,
+ description: "ID of this discussion."
field :noteable, Types::NoteableType, null: true,
description: 'Object which the discussion belongs to.'
+ field :notes, Types::Notes::NoteType.connection_type, null: false,
+ description: 'All notes in the discussion.'
+ field :reply_id, DiscussionID, null: false,
+ description: 'ID used to reply to this discussion.'
# DiscussionID.coerce_result is suitable here, but will always mark this
# as being a 'Discussion'. Using `GlobalId.build` guarantees that we get
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index 7314c137010..32f3ff7f556 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -33,17 +33,17 @@ module Types
method: :note,
description: 'Content of the note.'
+ field :confidential, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates if this note is confidential.',
+ method: :confidential?
field :created_at, Types::TimeType, null: false,
description: 'Timestamp of the note creation.'
- field :updated_at, Types::TimeType, null: false,
- description: "Timestamp of the note's last activity."
field :discussion, Types::Notes::DiscussionType, null: true,
description: 'Discussion this note is a part of.'
field :position, Types::Notes::DiffPositionType, null: true,
description: 'Position of this note on a diff.'
- field :confidential, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if this note is confidential.',
- method: :confidential?
+ field :updated_at, Types::TimeType, null: false,
+ description: "Timestamp of the note's last activity."
field :url, GraphQL::Types::String,
null: true,
description: 'URL to view this Note in the Web UI.'
diff --git a/app/graphql/types/packages/composer/json_type.rb b/app/graphql/types/packages/composer/json_type.rb
index 6c121043301..84f08adb021 100644
--- a/app/graphql/types/packages/composer/json_type.rb
+++ b/app/graphql/types/packages/composer/json_type.rb
@@ -8,9 +8,9 @@ module Types
graphql_name 'PackageComposerJsonType'
description 'Represents a composer JSON file'
+ field :license, GraphQL::Types::String, null: true, description: 'License 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
diff --git a/app/graphql/types/packages/composer/metadatum_type.rb b/app/graphql/types/packages/composer/metadatum_type.rb
index 092e729ec56..d28ee87b878 100644
--- a/app/graphql/types/packages/composer/metadatum_type.rb
+++ b/app/graphql/types/packages/composer/metadatum_type.rb
@@ -9,8 +9,8 @@ module Types
authorize :read_package
- field :target_sha, GraphQL::Types::String, null: false, description: 'Target SHA of the package.'
field :composer_json, Types::Packages::Composer::JsonType, null: false, description: 'Data of the Composer JSON file.'
+ field :target_sha, GraphQL::Types::String, null: false, description: 'Target SHA of the package.'
end
end
end
diff --git a/app/graphql/types/packages/conan/file_metadatum_type.rb b/app/graphql/types/packages/conan/file_metadatum_type.rb
index 9a26fd5de51..012e03ece8f 100644
--- a/app/graphql/types/packages/conan/file_metadatum_type.rb
+++ b/app/graphql/types/packages/conan/file_metadatum_type.rb
@@ -11,11 +11,11 @@ module Types
authorize :read_package
+ field :conan_file_type, ::Types::Packages::Conan::MetadatumFileTypeEnum, null: false, description: 'Type of the Conan file.'
+ field :conan_package_reference, GraphQL::Types::String, null: true, description: 'Reference of the Conan package.'
field :id, ::Types::GlobalIDType[::Packages::Conan::FileMetadatum], null: false, description: 'ID of the metadatum.'
- field :recipe_revision, GraphQL::Types::String, null: false, description: 'Revision of the Conan recipe.'
field :package_revision, GraphQL::Types::String, null: true, description: 'Revision of the package.'
- field :conan_package_reference, GraphQL::Types::String, null: true, description: 'Reference of the Conan package.'
- field :conan_file_type, ::Types::Packages::Conan::MetadatumFileTypeEnum, null: false, description: 'Type of the Conan file.'
+ field :recipe_revision, GraphQL::Types::String, null: false, description: 'Revision of the Conan recipe.'
end
end
end
diff --git a/app/graphql/types/packages/conan/metadatum_type.rb b/app/graphql/types/packages/conan/metadatum_type.rb
index cdfd0aa4483..d410d6d6d33 100644
--- a/app/graphql/types/packages/conan/metadatum_type.rb
+++ b/app/graphql/types/packages/conan/metadatum_type.rb
@@ -9,13 +9,13 @@ module Types
authorize :read_package
- field :id, ::Types::GlobalIDType[::Packages::Conan::Metadatum], null: false, description: 'ID of the metadatum.'
field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
- field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
- field :package_username, GraphQL::Types::String, null: false, description: 'Username of the Conan package.'
+ field :id, ::Types::GlobalIDType[::Packages::Conan::Metadatum], null: false, description: 'ID of the metadatum.'
field :package_channel, GraphQL::Types::String, null: false, description: 'Channel of the Conan package.'
+ field :package_username, GraphQL::Types::String, null: false, description: 'Username of the Conan package.'
field :recipe, GraphQL::Types::String, null: false, description: 'Recipe of the Conan package.'
field :recipe_path, GraphQL::Types::String, null: false, description: 'Recipe path of the Conan package.'
+ field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
end
end
end
diff --git a/app/graphql/types/packages/helm/dependency_type.rb b/app/graphql/types/packages/helm/dependency_type.rb
index 35598c2b1d7..72a47d0af51 100644
--- a/app/graphql/types/packages/helm/dependency_type.rb
+++ b/app/graphql/types/packages/helm/dependency_type.rb
@@ -9,14 +9,14 @@ module Types
description 'Represents a Helm dependency'
# Need to be synced with app/validators/json_schemas/helm_metadata.json#dependencies
- field :name, GraphQL::Types::String, null: true, description: 'Name of the dependency.'
- field :version, GraphQL::Types::String, null: true, description: 'Version of the dependency.'
- field :repository, GraphQL::Types::String, null: true, description: 'Repository of the dependency.'
+ field :alias, GraphQL::Types::String, null: true, description: 'Alias of the dependency.', resolver_method: :resolve_alias
field :condition, GraphQL::Types::String, null: true, description: 'Condition of the dependency.'
- field :tags, [GraphQL::Types::String], null: true, description: 'Tags of the dependency.'
field :enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates the dependency is enabled.'
field :import_values, [GraphQL::Types::JSON], null: true, description: 'Import-values of the dependency.', hash_key: "import-values" # rubocop:disable Graphql/JSONType
- field :alias, GraphQL::Types::String, null: true, description: 'Alias of the dependency.', resolver_method: :resolve_alias
+ field :name, GraphQL::Types::String, null: true, description: 'Name of the dependency.'
+ field :repository, GraphQL::Types::String, null: true, description: 'Repository of the dependency.'
+ field :tags, [GraphQL::Types::String], null: true, description: 'Tags of the dependency.'
+ field :version, GraphQL::Types::String, null: true, description: 'Version of the dependency.'
# field :alias` conflicts with a built-in method
def resolve_alias
diff --git a/app/graphql/types/packages/helm/maintainer_type.rb b/app/graphql/types/packages/helm/maintainer_type.rb
index 6d25a26c46b..e029ff6fd94 100644
--- a/app/graphql/types/packages/helm/maintainer_type.rb
+++ b/app/graphql/types/packages/helm/maintainer_type.rb
@@ -9,8 +9,8 @@ module Types
description 'Represents a Helm maintainer'
# Need to be synced with app/validators/json_schemas/helm_metadata.json#maintainers
- field :name, GraphQL::Types::String, null: true, description: 'Name of the maintainer.'
field :email, GraphQL::Types::String, null: true, description: 'Email of the maintainer.'
+ field :name, GraphQL::Types::String, null: true, description: 'Name of the maintainer.'
field :url, GraphQL::Types::String, null: true, description: 'URL of the maintainer.'
end
end
diff --git a/app/graphql/types/packages/helm/metadata_type.rb b/app/graphql/types/packages/helm/metadata_type.rb
index eeb3e8087a8..ccc5a3029cd 100644
--- a/app/graphql/types/packages/helm/metadata_type.rb
+++ b/app/graphql/types/packages/helm/metadata_type.rb
@@ -9,23 +9,23 @@ module Types
description 'Represents the contents of a Helm Chart.yml file'
# Need to be synced with app/validators/json_schemas/helm_metadata.json
- field :name, GraphQL::Types::String, null: false, description: 'Name of the chart.'
- field :home, GraphQL::Types::String, null: true, description: 'URL of the home page.'
- field :sources, [GraphQL::Types::String], null: true, description: 'URLs of the source code for the chart.'
- field :version, GraphQL::Types::String, null: false, description: 'Version of the chart.'
- field :description, GraphQL::Types::String, null: true, description: 'Description of the chart.'
- field :keywords, [GraphQL::Types::String], null: true, description: 'Keywords for the chart.'
- field :maintainers, [Types::Packages::Helm::MaintainerType], null: true, description: 'Maintainers of the chart.'
- field :icon, GraphQL::Types::String, null: true, description: 'URL to an SVG or PNG image for the chart.'
+ field :annotations, GraphQL::Types::JSON, null: true, description: 'Annotations for the chart.' # rubocop:disable Graphql/JSONType
field :api_version, GraphQL::Types::String, null: false, description: 'API version of the chart.', hash_key: "apiVersion"
- field :condition, GraphQL::Types::String, null: true, description: 'Condition for the chart.'
- field :tags, GraphQL::Types::String, null: true, description: 'Tags for the chart.'
field :app_version, GraphQL::Types::String, null: true, description: 'App version of the chart.', hash_key: "appVersion"
+ field :condition, GraphQL::Types::String, null: true, description: 'Condition for the chart.'
+ field :dependencies, [Types::Packages::Helm::DependencyType], null: true, description: 'Dependencies of the chart.'
field :deprecated, GraphQL::Types::Boolean, null: true, description: 'Indicates if the chart is deprecated.'
- field :annotations, GraphQL::Types::JSON, null: true, description: 'Annotations for the chart.' # rubocop:disable Graphql/JSONType
+ field :description, GraphQL::Types::String, null: true, description: 'Description of the chart.'
+ field :home, GraphQL::Types::String, null: true, description: 'URL of the home page.'
+ field :icon, GraphQL::Types::String, null: true, description: 'URL to an SVG or PNG image for the chart.'
+ field :keywords, [GraphQL::Types::String], null: true, description: 'Keywords for the chart.'
field :kube_version, GraphQL::Types::String, null: true, description: 'Kubernetes versions for the chart.', hash_key: "kubeVersion"
- field :dependencies, [Types::Packages::Helm::DependencyType], null: true, description: 'Dependencies of the chart.'
+ field :maintainers, [Types::Packages::Helm::MaintainerType], null: true, description: 'Maintainers of the chart.'
+ field :name, GraphQL::Types::String, null: false, description: 'Name of the chart.'
+ field :sources, [GraphQL::Types::String], null: true, description: 'URLs of the source code for the chart.'
+ field :tags, GraphQL::Types::String, null: true, description: 'Tags for the chart.'
field :type, GraphQL::Types::String, null: true, description: 'Type of the chart.', hash_key: "appVersion"
+ field :version, GraphQL::Types::String, null: false, description: 'Version of the chart.'
end
end
end
diff --git a/app/graphql/types/packages/maven/metadatum_type.rb b/app/graphql/types/packages/maven/metadatum_type.rb
index eb3829648d1..b59f5235d7b 100644
--- a/app/graphql/types/packages/maven/metadatum_type.rb
+++ b/app/graphql/types/packages/maven/metadatum_type.rb
@@ -9,13 +9,13 @@ module Types
authorize :read_package
- field :id, ::Types::GlobalIDType[::Packages::Maven::Metadatum], null: false, description: 'ID of the metadatum.'
- field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
- field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
- field :path, GraphQL::Types::String, null: false, description: 'Path of the Maven package.'
field :app_group, GraphQL::Types::String, null: false, description: 'App group of the Maven package.'
- field :app_version, GraphQL::Types::String, null: true, description: 'App version of the Maven package.'
field :app_name, GraphQL::Types::String, null: false, description: 'App name of the Maven package.'
+ field :app_version, GraphQL::Types::String, null: true, description: 'App version of the Maven package.'
+ field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
+ field :id, ::Types::GlobalIDType[::Packages::Maven::Metadatum], null: false, description: 'ID of the metadatum.'
+ field :path, GraphQL::Types::String, null: false, description: 'Path of the Maven package.'
+ field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
end
end
end
diff --git a/app/graphql/types/packages/nuget/metadatum_type.rb b/app/graphql/types/packages/nuget/metadatum_type.rb
index b58fd954a74..fd9f1039d3c 100644
--- a/app/graphql/types/packages/nuget/metadatum_type.rb
+++ b/app/graphql/types/packages/nuget/metadatum_type.rb
@@ -9,10 +9,10 @@ module Types
authorize :read_package
+ field :icon_url, GraphQL::Types::String, null: true, description: 'Icon URL of the Nuget package.'
field :id, ::Types::GlobalIDType[::Packages::Nuget::Metadatum], null: false, description: 'ID of the metadatum.'
field :license_url, GraphQL::Types::String, null: true, description: 'License URL of the Nuget package.'
field :project_url, GraphQL::Types::String, null: true, description: 'Project URL of the Nuget package.'
- field :icon_url, GraphQL::Types::String, null: true, description: 'Icon URL of the Nuget package.'
end
end
end
diff --git a/app/graphql/types/packages/package_dependency_link_type.rb b/app/graphql/types/packages/package_dependency_link_type.rb
index eceb8319748..8b1d4abf3ba 100644
--- a/app/graphql/types/packages/package_dependency_link_type.rb
+++ b/app/graphql/types/packages/package_dependency_link_type.rb
@@ -7,9 +7,9 @@ module Types
description 'Represents a package dependency link'
authorize :read_package
- field :id, ::Types::GlobalIDType[::Packages::DependencyLink], null: false, description: 'ID of the dependency link.'
- field :dependency_type, Types::Packages::PackageDependencyTypeEnum, null: false, description: 'Dependency type.'
field :dependency, Types::Packages::PackageDependencyType, null: true, description: 'Dependency.'
+ field :dependency_type, Types::Packages::PackageDependencyTypeEnum, null: false, description: 'Dependency type.'
+ field :id, ::Types::GlobalIDType[::Packages::DependencyLink], null: false, description: 'ID of the dependency link.'
field :metadata, Types::Packages::DependencyLinkMetadataType, null: true, description: 'Dependency link metadata.'
# NOTE: This method must be kept in sync with the union
diff --git a/app/graphql/types/packages/package_file_type.rb b/app/graphql/types/packages/package_file_type.rb
index f90a0992bf8..b058dc0ab0d 100644
--- a/app/graphql/types/packages/package_file_type.rb
+++ b/app/graphql/types/packages/package_file_type.rb
@@ -7,17 +7,17 @@ module Types
description 'Represents a package file'
authorize :read_package
- field :id, ::Types::GlobalIDType[::Packages::PackageFile], null: false, description: 'ID of the file.'
field :created_at, Types::TimeType, null: false, description: '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.'
field :file_md5, GraphQL::Types::String, null: true, description: 'Md5 of the package file.'
- field :file_sha1, GraphQL::Types::String, null: true, description: 'Sha1 of the package file.'
- field :file_sha256, GraphQL::Types::String, null: true, description: 'Sha256 of the package file.'
field :file_metadata, Types::Packages::FileMetadataType, null: true,
description: 'File metadata.'
+ field :file_name, GraphQL::Types::String, null: false, description: 'Name of the package file.'
+ field :file_sha1, GraphQL::Types::String, null: true, description: 'Sha1 of the package file.'
+ field :file_sha256, GraphQL::Types::String, null: true, description: 'Sha256 of the package file.'
+ field :id, ::Types::GlobalIDType[::Packages::PackageFile], null: false, description: 'ID of the file.'
+ field :size, GraphQL::Types::String, null: false, description: 'Size of the package file.'
+ field :updated_at, Types::TimeType, null: false, description: 'Updated date.'
# NOTE: This method must be kept in sync with the union
# type: `Types::Packages::FileMetadataType`.
diff --git a/app/graphql/types/packages/package_tag_type.rb b/app/graphql/types/packages/package_tag_type.rb
index f1f96c42e27..9d462e90b6f 100644
--- a/app/graphql/types/packages/package_tag_type.rb
+++ b/app/graphql/types/packages/package_tag_type.rb
@@ -7,9 +7,9 @@ module Types
description 'Represents a package tag'
authorize :read_package
+ field :created_at, Types::TimeType, null: false, description: 'Created 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
diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb
index d1312cb963d..1155be28e08 100644
--- a/app/graphql/types/packages/package_type.rb
+++ b/app/graphql/types/packages/package_type.rb
@@ -13,23 +13,23 @@ module Types
field :id, ::Types::GlobalIDType[::Packages::Package], null: false,
description: 'ID of the package.'
- field :name, GraphQL::Types::String, null: false, description: 'Name of the package.'
+ field :can_destroy, GraphQL::Types::Boolean, null: false, description: 'Whether the user can destroy the package.'
field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
- field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
- field :version, GraphQL::Types::String, null: true, description: 'Version string.'
+ field :metadata, Types::Packages::MetadataType, null: true,
+ description: 'Package metadata.'
+ field :name, GraphQL::Types::String, null: false, description: 'Name of the package.'
field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'Package type.'
- field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'Package tags.'
- field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.'
field :pipelines, Types::Ci::PipelineType.connection_type, null: true,
description: 'Pipelines that built the package.',
deprecated: { reason: 'Due to scalability concerns, this field is going to be removed', milestone: '14.6' }
- field :metadata, Types::Packages::MetadataType, null: true,
- description: 'Package metadata.'
+ field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.'
+ field :status, Types::Packages::PackageStatusEnum, null: false, description: 'Package status.'
+ field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'Package tags.'
+ field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
+ field :version, GraphQL::Types::String, null: true, description: 'Version string.'
field :versions, ::Types::Packages::PackageType.connection_type, null: true,
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.'
- field :can_destroy, GraphQL::Types::Boolean, null: false, description: 'Whether the user can destroy the package.'
def project
Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb
index ab2b9c2a3af..1146774b43c 100644
--- a/app/graphql/types/project_statistics_type.rb
+++ b/app/graphql/types/project_statistics_type.rb
@@ -9,23 +9,23 @@ module Types
field :commit_count, GraphQL::Types::Float, null: false,
description: 'Commit count of the project.'
- field :storage_size, GraphQL::Types::Float, null: false,
- description: 'Storage size of the project in bytes.'
- field :repository_size, GraphQL::Types::Float, null: false,
- description: 'Repository size of the project in bytes.'
- field :lfs_objects_size, GraphQL::Types::Float, null: false,
- description: 'Large File Storage (LFS) object size of the project in bytes.'
field :build_artifacts_size, GraphQL::Types::Float, null: false,
description: 'Build artifacts size of the project in bytes.'
+ field :lfs_objects_size, GraphQL::Types::Float, null: false,
+ description: 'Large File Storage (LFS) object size of the project in bytes.'
field :packages_size, GraphQL::Types::Float, null: false,
description: 'Packages size of the project in bytes.'
- field :wiki_size, GraphQL::Types::Float, null: true,
- description: 'Wiki size of the project in bytes.'
- field :snippets_size, GraphQL::Types::Float, null: true,
- description: 'Snippets size of the project in bytes.'
field :pipeline_artifacts_size, GraphQL::Types::Float, null: true,
description: 'CI Pipeline artifacts size in bytes.'
+ field :repository_size, GraphQL::Types::Float, null: false,
+ description: 'Repository size of the project in bytes.'
+ field :snippets_size, GraphQL::Types::Float, null: true,
+ description: 'Snippets size of the project in bytes.'
+ field :storage_size, GraphQL::Types::Float, null: false,
+ description: 'Storage size of the project in bytes.'
field :uploads_size, GraphQL::Types::Float, null: true,
description: 'Uploads size of the project in bytes.'
+ field :wiki_size, GraphQL::Types::Float, null: true,
+ description: 'Wiki size of the project in bytes.'
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index dc428e7bdce..47e9a6c11fc 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -11,43 +11,43 @@ module Types
field :id, GraphQL::Types::ID, null: false,
description: 'ID of the project.'
+ field :ci_config_path_or_default, GraphQL::Types::String, null: false,
+ description: 'Path of the CI configuration file.'
field :full_path, GraphQL::Types::ID, null: false,
description: 'Full path of the project.'
field :path, GraphQL::Types::String, null: false,
description: 'Path of the project.'
- field :ci_config_path_or_default, GraphQL::Types::String, null: false,
- description: 'Path of the CI configuration file.'
field :sast_ci_configuration, Types::CiConfiguration::Sast::Type, null: true,
calls_gitaly: true,
description: 'SAST CI configuration for the project.'
- field :name_with_namespace, GraphQL::Types::String, null: false,
- description: 'Full name of the project with its namespace.'
field :name, GraphQL::Types::String, null: false,
description: 'Name of the project (without namespace).'
+ field :name_with_namespace, GraphQL::Types::String, null: false,
+ description: 'Full name of the project with its namespace.'
field :description, GraphQL::Types::String, null: true,
description: 'Short description of the project.'
field :tag_list, GraphQL::Types::String, null: true,
deprecated: { reason: 'Use `topics`', milestone: '13.12' },
- description: 'List of project topics (not Git tags).'
+ description: 'List of project topics (not Git tags).', method: :topic_list
field :topics, [GraphQL::Types::String], null: true,
- description: 'List of project topics.'
+ description: 'List of project topics.', method: :topic_list
- field :ssh_url_to_repo, GraphQL::Types::String, null: true,
- description: 'URL to connect to the project via SSH.'
field :http_url_to_repo, GraphQL::Types::String, null: true,
description: 'URL to connect to the project via HTTPS.'
+ field :ssh_url_to_repo, GraphQL::Types::String, null: true,
+ description: 'URL to connect to the project via SSH.'
field :web_url, GraphQL::Types::String, null: true,
description: 'Web URL of the project.'
- field :star_count, GraphQL::Types::Int, null: false,
- description: 'Number of times the project has been starred.'
field :forks_count, GraphQL::Types::Int, null: false, calls_gitaly: true, # 4 times
description: 'Number of times the project has been forked.'
+ field :star_count, GraphQL::Types::Int, null: false,
+ description: 'Number of times the project has been starred.'
field :created_at, Types::TimeType, null: true,
description: 'Timestamp of the project creation.'
@@ -60,12 +60,12 @@ module Types
field :visibility, GraphQL::Types::String, null: true,
description: 'Visibility of the project.'
- field :shared_runners_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if shared runners are enabled for the project.'
field :lfs_enabled, GraphQL::Types::Boolean, null: true,
description: 'Indicates if the project has Large File Storage (LFS) enabled.'
field :merge_requests_ff_only_enabled, GraphQL::Types::Boolean, null: true,
description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.'
+ field :shared_runners_enabled, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates if shared runners are enabled for the project.'
field :service_desk_enabled, GraphQL::Types::Boolean, null: true,
description: 'Indicates if the project has service desk enabled.'
@@ -85,33 +85,35 @@ module Types
field :open_issues_count, GraphQL::Types::Int, null: true,
description: 'Number of open issues for the project.'
+ field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, null: true,
+ description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of the project can also be merged with skipped jobs.'
+ 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 :import_status, GraphQL::Types::String, null: true,
description: 'Status of import background job of the project.'
field :jira_import_status, GraphQL::Types::String, null: true,
description: 'Status of Jira import background job of the project.'
- field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if merge requests of the project can only be merged with successful jobs.'
- field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, null: true,
- description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of the project can also be merged with skipped jobs.'
- field :request_access_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if users can request member access to the project.'
field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::Types::Boolean, null: true,
description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.'
+ field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates if merge requests of the project can only be merged with successful jobs.'
field :printing_merge_request_link_enabled, GraphQL::Types::Boolean, null: true,
description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line.'
field :remove_source_branch_after_merge, GraphQL::Types::Boolean, null: true,
description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project.'
- 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: 'Commit message used to apply merge request suggestions.'
+ field :request_access_enabled, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates if users can request member access to the project.'
field :squash_read_only, GraphQL::Types::Boolean, null: false, method: :squash_readonly?,
description: 'Indicates if `squashReadOnly` is enabled.'
+ field :suggestion_commit_message, GraphQL::Types::String, null: true,
+ description: 'Commit message used to apply merge request suggestions.'
+ # No, the quotes are not a typo. Used to get around circular dependencies.
+ # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536#note_871009675
+ field :group, 'Types::GroupType', null: true,
+ description: 'Group of the project.'
field :namespace, Types::NamespaceType, null: true,
description: 'Namespace of the project.'
- field :group, Types::GroupType, null: true,
- description: 'Group of the project.'
field :statistics, Types::ProjectStatisticsType,
null: true,
@@ -397,8 +399,9 @@ module Types
field :work_item_types, Types::WorkItems::TypeType.connection_type,
resolver: Resolvers::WorkItems::TypesResolver,
- description: 'Work item types available to the project.',
- feature_flag: :work_items
+ description: 'Work item types available to the project.' \
+ ' Returns `null` if `work_items` feature flag is disabled.' \
+ ' This flag is disabled by default, because the feature is experimental and is subject to change without notice.'
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
@@ -458,14 +461,6 @@ module Types
object.service_desk_address
end
- def tag_list
- object.topic_list
- end
-
- def topics
- object.topic_list
- end
-
private
def project
diff --git a/app/graphql/types/projects/service_type.rb b/app/graphql/types/projects/service_type.rb
index 4a9e5dcbfe9..88b7b95aa57 100644
--- a/app/graphql/types/projects/service_type.rb
+++ b/app/graphql/types/projects/service_type.rb
@@ -10,9 +10,20 @@ module Types
# https://gitlab.com/gitlab-org/gitlab/-/issues/213088
field :type, GraphQL::Types::String, null: true,
description: 'Class name of the service.'
+ field :service_type, ::Types::Projects::ServiceTypeEnum, null: true,
+ description: 'Type of the service.'
field :active, GraphQL::Types::Boolean, null: true,
description: 'Indicates if the service is active.'
+ def type
+ enum = ::Types::Projects::ServiceTypeEnum.coerce_result(service_type, context)
+ enum.downcase.camelize
+ end
+
+ def service_type
+ object.type
+ end
+
definition_methods do
def resolve_type(object, context)
if object.is_a?(::Integrations::Jira)
diff --git a/app/graphql/types/projects/service_type_enum.rb b/app/graphql/types/projects/service_type_enum.rb
index 027026dc16c..d0cecbfea49 100644
--- a/app/graphql/types/projects/service_type_enum.rb
+++ b/app/graphql/types/projects/service_type_enum.rb
@@ -5,8 +5,21 @@ module Types
class ServiceTypeEnum < BaseEnum
graphql_name 'ServiceType'
- ::Integration.available_integration_types(include_dev: false).each do |type|
- value type.underscore.upcase, value: type, description: "#{type} type"
+ class << self
+ private
+
+ def type_description(name, type)
+ "#{type} type"
+ end
+ end
+
+ # This prepend must stay here because the dynamic block below depends on it.
+ prepend_mod # rubocop: disable Cop/InjectEnterpriseEditionModule
+
+ ::Integration.available_integration_names(include_dev: false).each do |name|
+ type = "#{name.camelize}Service"
+ domain_value = Integration.integration_name_to_type(name)
+ value type.underscore.upcase, value: domain_value, description: type_description(name, type)
end
end
end
diff --git a/app/graphql/types/projects/services/jira_project_type.rb b/app/graphql/types/projects/services/jira_project_type.rb
index 957ac91db6b..0ff1b9d8903 100644
--- a/app/graphql/types/projects/services/jira_project_type.rb
+++ b/app/graphql/types/projects/services/jira_project_type.rb
@@ -9,11 +9,11 @@ module Types
field :key, GraphQL::Types::String, null: false,
description: 'Key of the Jira project.'
+ field :name, GraphQL::Types::String, null: true,
+ description: 'Name of the Jira project.'
field :project_id, GraphQL::Types::Int, null: false,
description: 'ID of the Jira project.',
method: :id
- field :name, GraphQL::Types::String, null: true,
- description: 'Name of the Jira project.'
end
# rubocop:enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 4a4d6727c3f..cc46c7e86e4 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -87,6 +87,12 @@ module Types
argument :id, ::Types::GlobalIDType[::Issue], required: true, description: 'Global ID of the issue.'
end
+ field :work_item, Types::WorkItemType,
+ null: true,
+ resolver: Resolvers::WorkItemResolver,
+ description: 'Find a work item. Returns `null` if `work_items` feature flag is disabled.' \
+ ' The feature is experimental and is subject to change without notice.'
+
field :merge_request, Types::MergeRequestType,
null: true,
description: 'Find a merge request.' do
@@ -145,6 +151,10 @@ module Types
resolver: Resolvers::TopicsResolver,
description: "Find project topics."
+ field :gitpod_enabled, GraphQL::Types::Boolean,
+ null: true,
+ description: "Whether Gitpod is enabled in application settings."
+
def design_management
DesignManagementObject.new(nil)
end
@@ -189,6 +199,10 @@ module Types
Gitlab::CurrentSettings.current_application_settings
end
+ def gitpod_enabled
+ application_settings.gitpod_enabled
+ end
+
def query_complexity
context.query
end
diff --git a/app/graphql/types/release_asset_link_type.rb b/app/graphql/types/release_asset_link_type.rb
index 02961f2f73f..33dcb5125e3 100644
--- a/app/graphql/types/release_asset_link_type.rb
+++ b/app/graphql/types/release_asset_link_type.rb
@@ -7,21 +7,21 @@ module Types
authorize :read_release
+ field :external, GraphQL::Types::Boolean, null: true, method: :external?,
+ description: 'Indicates the link points to an external resource.'
field :id, GraphQL::Types::ID, null: false,
description: 'ID of the link.'
+ field :link_type, Types::ReleaseAssetLinkTypeEnum, null: true,
+ description: 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`.'
field :name, GraphQL::Types::String, null: true,
description: 'Name of the link.'
field :url, GraphQL::Types::String, null: true,
description: 'URL of the link.'
- field :link_type, Types::ReleaseAssetLinkTypeEnum, null: true,
- description: 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`.'
- field :external, GraphQL::Types::Boolean, null: true, method: :external?,
- description: 'Indicates the link points to an external resource.'
- field :direct_asset_url, GraphQL::Types::String, null: true,
- description: 'Direct asset URL of the link.'
field :direct_asset_path, GraphQL::Types::String, null: true, method: :filepath,
description: 'Relative path for the direct asset link.'
+ field :direct_asset_url, GraphQL::Types::String, null: true,
+ description: 'Direct asset URL of the link.'
def direct_asset_url
return object.url unless object.filepath
diff --git a/app/graphql/types/release_links_type.rb b/app/graphql/types/release_links_type.rb
index 37ad52ce6d0..b7a1a5a9dbe 100644
--- a/app/graphql/types/release_links_type.rb
+++ b/app/graphql/types/release_links_type.rb
@@ -10,25 +10,25 @@ module Types
present_using ReleasePresenter
- field :self_url, GraphQL::Types::String, null: true,
- description: 'HTTP URL of the release.'
+ field :closed_issues_url, GraphQL::Types::String, null: true,
+ description: 'HTTP URL of the issues page, filtered by this release and `state=closed`.',
+ authorize: :download_code
+ field :closed_merge_requests_url, GraphQL::Types::String, null: true,
+ description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`.',
+ authorize: :download_code
field :edit_url, GraphQL::Types::String, null: true,
description: "HTTP URL of the release's edit page.",
authorize: :update_release
- field :opened_merge_requests_url, GraphQL::Types::String, null: true,
- description: 'HTTP URL of the merge request page, filtered by this release and `state=open`.',
- authorize: :download_code
field :merged_merge_requests_url, GraphQL::Types::String, null: true,
description: 'HTTP URL of the merge request page , filtered by this release and `state=merged`.',
authorize: :download_code
- field :closed_merge_requests_url, GraphQL::Types::String, null: true,
- description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`.',
- authorize: :download_code
field :opened_issues_url, GraphQL::Types::String, null: true,
description: 'HTTP URL of the issues page, filtered by this release and `state=open`.',
authorize: :download_code
- field :closed_issues_url, GraphQL::Types::String, null: true,
- description: 'HTTP URL of the issues page, filtered by this release and `state=closed`.',
+ field :opened_merge_requests_url, GraphQL::Types::String, null: true,
+ description: 'HTTP URL of the merge request page, filtered by this release and `state=open`.',
authorize: :download_code
+ field :self_url, GraphQL::Types::String, null: true,
+ description: 'HTTP URL of the release.'
end
end
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
index fbc3779ea9b..95b6b43bb46 100644
--- a/app/graphql/types/release_type.rb
+++ b/app/graphql/types/release_type.rb
@@ -13,30 +13,30 @@ module Types
present_using ReleasePresenter
- field :tag_name, GraphQL::Types::String, null: true, method: :tag,
- description: 'Name of the tag associated with the release.'
- field :tag_path, GraphQL::Types::String, null: true,
- description: 'Relative web path to the tag associated with the release.',
- authorize: :download_code
+ field :assets, Types::ReleaseAssetsType, null: true, method: :itself,
+ description: 'Assets of the release.'
+ field :created_at, Types::TimeType, null: true,
+ description: 'Timestamp of when the release was created.'
field :description, GraphQL::Types::String, null: true,
description: 'Description (also known as "release notes") of the release.'
+ field :evidences, Types::EvidenceType.connection_type, null: true,
+ description: 'Evidence for the release.'
+ field :links, Types::ReleaseLinksType, null: true, method: :itself,
+ description: 'Links of the release.'
+ field :milestones, Types::MilestoneType.connection_type, null: true,
+ description: 'Milestones associated to the release.',
+ resolver: ::Resolvers::ReleaseMilestonesResolver
field :name, GraphQL::Types::String, null: true,
description: 'Name of the release.'
- field :created_at, Types::TimeType, null: true,
- description: 'Timestamp of when the release was created.'
field :released_at, Types::TimeType, null: true,
description: 'Timestamp of when the release was released.'
+ field :tag_name, GraphQL::Types::String, null: true, method: :tag,
+ description: 'Name of the tag associated with the release.'
+ field :tag_path, GraphQL::Types::String, null: true,
+ description: 'Relative web path to the tag associated with the release.',
+ authorize: :download_code
field :upcoming_release, GraphQL::Types::Boolean, null: true, method: :upcoming_release?,
description: 'Indicates the release is an upcoming release.'
- field :assets, Types::ReleaseAssetsType, null: true, method: :itself,
- description: 'Assets of the release.'
- field :links, Types::ReleaseLinksType, null: true, method: :itself,
- description: 'Links of the release.'
- field :milestones, Types::MilestoneType.connection_type, null: true,
- description: 'Milestones associated to the release.',
- resolver: ::Resolvers::ReleaseMilestonesResolver
- field :evidences, Types::EvidenceType.connection_type, null: true,
- description: 'Evidence for the release.'
field :author, Types::UserType, null: true,
description: 'User that created the release.'
diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb
index bfd59763a07..652e2882584 100644
--- a/app/graphql/types/repository/blob_type.rb
+++ b/app/graphql/types/repository/blob_type.rb
@@ -41,6 +41,9 @@ module Types
field :ide_fork_and_edit_path, GraphQL::Types::String, null: true,
description: 'Web path to edit this blob in the Web IDE using a forked project.'
+ field :fork_and_view_path, GraphQL::Types::String, null: true,
+ description: 'Web path to view this blob using a forked project.'
+
field :size, GraphQL::Types::Int, null: true,
description: 'Size (in bytes) of the blob.'
@@ -74,6 +77,9 @@ module Types
field :pipeline_editor_path, GraphQL::Types::String, null: true,
description: 'Web path to edit .gitlab-ci.yml file.'
+ field :gitpod_blob_url, GraphQL::Types::String, null: true,
+ description: 'URL to the blob within Gitpod.'
+
field :find_file_path, GraphQL::Types::String, null: true,
description: 'Web path to find file.'
@@ -131,6 +137,12 @@ module Types
null: true,
calls_gitaly: true
+ field :code_navigation_path, GraphQL::Types::String, null: true, calls_gitaly: true,
+ description: 'Web path for code navigation.'
+
+ field :project_blob_path_root, GraphQL::Types::String, null: true,
+ description: 'Web path for the root of the blob.'
+
def raw_text_blob
object.data unless object.binary?
end
diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb
index fc9860900c9..aa02f0058da 100644
--- a/app/graphql/types/repository_type.rb
+++ b/app/graphql/types/repository_type.rb
@@ -6,17 +6,6 @@ module Types
authorize :download_code
- field :root_ref, GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'Default branch of the repository.'
- field :empty, GraphQL::Types::Boolean, null: false, method: :empty?, calls_gitaly: true,
- description: 'Indicates repository has no visible content.'
- field :exists, GraphQL::Types::Boolean, null: false, method: :exists?, calls_gitaly: true,
- description: 'Indicates a corresponding Git repository exists on disk.'
- field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true,
- description: 'Tree of the repository.'
- field :paginated_tree, Types::Tree::TreeType.connection_type, null: true, resolver: Resolvers::PaginatedTreeResolver, calls_gitaly: true,
- max_page_size: 100,
- description: 'Paginated tree of the repository.'
field :blobs, Types::Repository::BlobType.connection_type, null: true, resolver: Resolvers::BlobsResolver, calls_gitaly: true,
description: 'Blobs contained within the repository'
field :branch_names, [GraphQL::Types::String], null: true, calls_gitaly: true,
@@ -26,5 +15,16 @@ module Types
description: 'Shows a disk path of the repository.',
null: true,
authorize: :read_storage_disk_path
+ field :empty, GraphQL::Types::Boolean, null: false, method: :empty?, calls_gitaly: true,
+ description: 'Indicates repository has no visible content.'
+ field :exists, GraphQL::Types::Boolean, null: false, method: :exists?, calls_gitaly: true,
+ description: 'Indicates a corresponding Git repository exists on disk.'
+ field :paginated_tree, Types::Tree::TreeType.connection_type, null: true, resolver: Resolvers::PaginatedTreeResolver, calls_gitaly: true,
+ max_page_size: 100,
+ description: 'Paginated tree of the repository.'
+ field :root_ref, GraphQL::Types::String, null: true, calls_gitaly: true,
+ description: 'Default branch of the repository.'
+ field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true,
+ description: 'Tree of the repository.'
end
end
diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb
index 4dcadf1274f..467331c5643 100644
--- a/app/graphql/types/root_storage_statistics_type.rb
+++ b/app/graphql/types/root_storage_statistics_type.rb
@@ -6,15 +6,15 @@ module Types
authorize :read_statistics
- field :storage_size, GraphQL::Types::Float, null: false, description: 'Total storage in bytes.'
- field :repository_size, GraphQL::Types::Float, null: false, description: 'Git repository size in bytes.'
- field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'LFS objects size in bytes.'
field :build_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI artifacts size in bytes.'
+ field :dependency_proxy_size, GraphQL::Types::Float, null: false, description: 'Dependency Proxy sizes in bytes.'
+ field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'LFS objects size in bytes.'
field :packages_size, GraphQL::Types::Float, null: false, description: 'Packages size in bytes.'
- field :wiki_size, GraphQL::Types::Float, null: false, description: 'Wiki size in bytes.'
- field :snippets_size, GraphQL::Types::Float, null: false, description: 'Snippets size in bytes.'
field :pipeline_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI pipeline artifacts size in bytes.'
+ field :repository_size, GraphQL::Types::Float, null: false, description: 'Git repository size in bytes.'
+ field :snippets_size, GraphQL::Types::Float, null: false, description: 'Snippets size in bytes.'
+ field :storage_size, GraphQL::Types::Float, null: false, description: 'Total storage in bytes.'
field :uploads_size, GraphQL::Types::Float, null: false, description: 'Uploads size in bytes.'
- field :dependency_proxy_size, GraphQL::Types::Float, null: false, description: 'Dependency Proxy sizes in bytes.'
+ field :wiki_size, GraphQL::Types::Float, null: false, description: 'Wiki size in bytes.'
end
end
diff --git a/app/graphql/types/saved_reply_type.rb b/app/graphql/types/saved_reply_type.rb
new file mode 100644
index 00000000000..329f431b10e
--- /dev/null
+++ b/app/graphql/types/saved_reply_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ class SavedReplyType < BaseObject
+ graphql_name 'SavedReply'
+
+ authorize :read_saved_replies
+
+ field :id, Types::GlobalIDType[::Users::SavedReply],
+ null: false,
+ description: 'Global ID of the saved reply.'
+
+ field :content, GraphQL::Types::String,
+ null: false,
+ description: 'Content of the saved reply.'
+
+ field :name, GraphQL::Types::String,
+ null: false,
+ description: 'Name of the saved reply.'
+ end
+end
diff --git a/app/graphql/types/task_completion_status.rb b/app/graphql/types/task_completion_status.rb
index 3aa19ff9413..9a979b04d37 100644
--- a/app/graphql/types/task_completion_status.rb
+++ b/app/graphql/types/task_completion_status.rb
@@ -8,10 +8,10 @@ module Types
graphql_name 'TaskCompletionStatus'
description 'Completion status of tasks'
- field :count, GraphQL::Types::Int, null: false,
- description: 'Number of total tasks.'
field :completed_count, GraphQL::Types::Int, null: false,
description: 'Number of completed tasks.'
+ field :count, GraphQL::Types::Int, null: false,
+ description: 'Number of total tasks.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb
index 34ba2c75b5f..f21b2b261a3 100644
--- a/app/graphql/types/todo_type.rb
+++ b/app/graphql/types/todo_type.rb
@@ -18,7 +18,7 @@ module Types
null: true,
authorize: :read_project
- field :group, Types::GroupType,
+ field :group, 'Types::GroupType',
description: 'Group this to-do item is associated with.',
null: true,
authorize: :read_group
@@ -31,6 +31,11 @@ module Types
description: 'Action of the to-do item.',
null: false
+ field :target, Types::TodoableInterface,
+ description: 'Target of the to-do item.',
+ calls_gitaly: true,
+ null: false
+
field :target_type, Types::TodoTargetEnum,
description: 'Target type of the to-do item.',
null: false
@@ -59,5 +64,28 @@ module Types
def author
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
end
+
+ def target
+ if object.for_commit?
+ Gitlab::Graphql::Loaders::BatchCommitLoader.new(
+ container_class: Project,
+ container_id: object.project_id,
+ oid: object.commit_id
+ ).find
+ else
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(target_type_class, object.target_id).find
+ end
+ end
+
+ private
+
+ def target_type_class
+ klass = object.target_type.safe_constantize
+ raise "Invalid target type \"#{object.target_type}\"" unless klass < Todoable
+
+ klass
+ end
end
end
+
+Types::TodoType.prepend_mod
diff --git a/app/graphql/types/todoable_interface.rb b/app/graphql/types/todoable_interface.rb
new file mode 100644
index 00000000000..7d437973c12
--- /dev/null
+++ b/app/graphql/types/todoable_interface.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Types
+ module TodoableInterface
+ include Types::BaseInterface
+
+ graphql_name 'Todoable'
+
+ field :web_url, GraphQL::Types::String, null: true, description: 'URL of this object.'
+
+ def self.resolve_type(object, context)
+ case object
+ when Issue
+ Types::IssueType
+ when MergeRequest
+ Types::MergeRequestType
+ when ::DesignManagement::Design
+ Types::DesignManagement::DesignType
+ when ::AlertManagement::Alert
+ Types::AlertManagement::AlertType
+ when Commit
+ Types::CommitType
+ else
+ raise "Unknown GraphQL type for #{object}"
+ end
+ end
+ end
+end
+
+Types::TodoableInterface.prepend_mod
diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb
index bcff65be652..284542e1d2a 100644
--- a/app/graphql/types/tree/blob_type.rb
+++ b/app/graphql/types/tree/blob_type.rb
@@ -9,15 +9,15 @@ module Types
implements Types::Tree::EntryType
present_using BlobPresenter
- field :web_url, GraphQL::Types::String, null: true,
- description: 'Web URL of the blob.'
- field :web_path, GraphQL::Types::String, null: true,
- description: 'Web path of the blob.'
field :lfs_oid, GraphQL::Types::String, null: true,
calls_gitaly: true,
description: 'LFS ID of the blob.'
field :mode, GraphQL::Types::String, null: true,
description: 'Blob mode in numeric format.'
+ field :web_path, GraphQL::Types::String, null: true,
+ description: 'Web path of the blob.'
+ field :web_url, GraphQL::Types::String, null: true,
+ description: 'Web URL of the blob.'
def lfs_oid
Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(object.repository, object.id).find
diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb
index bc7828dbffa..8f462011f0f 100644
--- a/app/graphql/types/tree/submodule_type.rb
+++ b/app/graphql/types/tree/submodule_type.rb
@@ -8,10 +8,10 @@ module Types
implements Types::Tree::EntryType
- field :web_url, type: GraphQL::Types::String, null: true,
- description: 'Web URL for the sub-module.'
field :tree_url, type: GraphQL::Types::String, null: true,
description: 'Tree URL for the sub-module.'
+ field :web_url, type: GraphQL::Types::String, null: true,
+ description: 'Web URL for the sub-module.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb
index cdc84c8e318..28024fd010b 100644
--- a/app/graphql/types/tree/tree_entry_type.rb
+++ b/app/graphql/types/tree/tree_entry_type.rb
@@ -10,10 +10,10 @@ module Types
implements Types::Tree::EntryType
present_using TreeEntryPresenter
- field :web_url, GraphQL::Types::String, null: true,
- description: 'Web URL for the tree entry (directory).'
field :web_path, GraphQL::Types::String, null: true,
description: 'Web path for the tree entry (directory).'
+ field :web_url, GraphQL::Types::String, null: true,
+ description: 'Web URL for the tree entry (directory).'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/user_callout_type.rb b/app/graphql/types/user_callout_type.rb
index 0ff32d68400..526027322ef 100644
--- a/app/graphql/types/user_callout_type.rb
+++ b/app/graphql/types/user_callout_type.rb
@@ -4,9 +4,9 @@ module Types
class UserCalloutType < BaseObject # rubocop:disable Graphql/AuthorizeTypes
graphql_name 'UserCallout'
- field :feature_name, UserCalloutFeatureNameEnum, null: true,
- description: 'Name of the feature that the callout is for.'
field :dismissed_at, Types::TimeType, null: true,
description: 'Date when the callout was dismissed.'
+ field :feature_name, UserCalloutFeatureNameEnum, null: true,
+ description: 'Name of the feature that the callout is for.'
end
end
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index 24fca80d5a9..2c9592a7f5a 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -115,6 +115,19 @@ module Types
extras: [:lookahead],
complexity: 5,
resolver: ::Resolvers::TimelogResolver
+ field :saved_replies,
+ Types::SavedReplyType.connection_type,
+ null: true,
+ description: 'Saved replies authored by the user.'
+
+ field :gitpod_enabled, GraphQL::Types::Boolean, null: true,
+ description: 'Whether Gitpod is enabled at the user level.'
+
+ field :preferences_gitpod_path, GraphQL::Types::String, null: true,
+ description: 'Web path to the Gitpod section within user preferences.'
+
+ field :profile_enable_gitpod_path, GraphQL::Types::String, null: true,
+ description: 'Web path to enable Gitpod for the user.'
definition_methods do
def resolve_type(object, context)
@@ -125,14 +138,7 @@ module Types
end
def redacted_name
- return object.name unless object.project_bot?
-
- return object.name if context[:current_user]&.can?(:read_project, object.projects.first)
-
- # If the requester does not have permission to read the project bot name,
- # the API returns an arbitrary string. UI changes will be addressed in a follow up issue:
- # https://gitlab.com/gitlab-org/gitlab/-/issues/346058
- '****'
+ object.redacted_name(context[:current_user])
end
end
end
diff --git a/app/graphql/types/user_status_type.rb b/app/graphql/types/user_status_type.rb
index 61abec0ba96..68c00bffe48 100644
--- a/app/graphql/types/user_status_type.rb
+++ b/app/graphql/types/user_status_type.rb
@@ -7,11 +7,11 @@ module Types
markdown_field :message_html, null: true,
description: 'HTML of the user status message'
- field :message, GraphQL::Types::String, null: true,
- description: 'User status message.'
- field :emoji, GraphQL::Types::String, null: true,
- description: 'String representation of emoji.'
field :availability, Types::AvailabilityEnum, null: false,
description: 'User availability status.'
+ field :emoji, GraphQL::Types::String, null: true,
+ description: 'String representation of emoji.'
+ field :message, GraphQL::Types::String, null: true,
+ description: 'User status message.'
end
end
diff --git a/app/graphql/types/work_item_id_type.rb b/app/graphql/types/work_item_id_type.rb
new file mode 100644
index 00000000000..ddcf3416014
--- /dev/null
+++ b/app/graphql/types/work_item_id_type.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop:disable Graphql/AuthorizeTypes
+ # TODO: This type should be removed when Work Items become generally available.
+ # This mechanism is introduced temporarily to make the client implementation easier during this transition.
+ class WorkItemIdType < GlobalIDType
+ graphql_name 'WorkItemID'
+ description <<~DESC
+ A `WorkItemID` is a global ID. It is encoded as a string.
+
+ An example `WorkItemID` is: `"gid://gitlab/WorkItem/1"`.
+
+ While we transition from Issues into Work Items this type will temporarily support
+ `IssueID` like: `"gid://gitlab/Issue/1"`. This behavior will be removed without notice in the future.
+ DESC
+
+ class << self
+ def coerce_result(gid, ctx)
+ global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: 'WorkItem')
+
+ raise GraphQL::CoercionError, "Expected a WorkItem ID, got #{global_id}" unless suitable?(global_id)
+
+ # Always return a WorkItemID even if an Issue is returned by a resolver
+ work_item_gid(global_id).to_s
+ end
+
+ def coerce_input(string, ctx)
+ gid = super
+ # Always return a WorkItemID even if an Issue Global ID is provided as input
+ return work_item_gid(gid) if suitable?(gid)
+
+ raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of WorkItem"
+ end
+
+ def suitable?(gid)
+ return false if gid&.model_name&.safe_constantize.blank?
+
+ [::WorkItem, ::Issue].any? { |model_class| gid.model_class == model_class }
+ end
+
+ private
+
+ def work_item_gid(gid)
+ GlobalID.new(::Gitlab::GlobalId.build(model_name: 'WorkItem', id: gid.model_id))
+ end
+ end
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb
index 15a5557b489..512b9ef64d2 100644
--- a/app/graphql/types/work_item_type.rb
+++ b/app/graphql/types/work_item_type.rb
@@ -4,7 +4,7 @@ module Types
class WorkItemType < BaseObject
graphql_name 'WorkItem'
- authorize :read_issue
+ authorize :read_work_item
field :description, GraphQL::Types::String, null: true,
description: 'Description of the work item.'
@@ -12,6 +12,8 @@ module Types
description: 'Global ID of the work item.'
field :iid, GraphQL::Types::ID, null: false,
description: 'Internal ID of the work item.'
+ field :lock_version, GraphQL::Types::Int, null: false,
+ description: 'Lock version of the work item. Incremented each time the work item is updated.'
field :state, WorkItemStateEnum, null: false,
description: 'State of the work item.'
field :title, GraphQL::Types::String, null: false,
diff --git a/app/graphql/types/work_items/convert_task_input_type.rb b/app/graphql/types/work_items/convert_task_input_type.rb
new file mode 100644
index 00000000000..1f142c6815c
--- /dev/null
+++ b/app/graphql/types/work_items/convert_task_input_type.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ class ConvertTaskInputType < BaseInputObject
+ graphql_name 'WorkItemConvertTaskInput'
+
+ argument :line_number_end, GraphQL::Types::Int,
+ required: true,
+ description: 'Last line in the Markdown source that defines the list item task.'
+ argument :line_number_start, GraphQL::Types::Int,
+ required: true,
+ description: 'First line in the Markdown source that defines the list item task.'
+ argument :lock_version, GraphQL::Types::Int,
+ required: true,
+ description: 'Current lock version of the work item containing the task in the description.'
+ argument :title, GraphQL::Types::String,
+ required: true,
+ description: 'Full string of the task to be replaced. New title for the created work item.'
+ argument :work_item_type_id, ::Types::GlobalIDType[::WorkItems::Type],
+ required: true,
+ description: 'Global ID of the work item type used to create the new work item.',
+ prepare: ->(attribute, _ctx) { work_item_type_global_id(attribute) }
+
+ class << self
+ def work_item_type_global_id(global_id)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ global_id = ::Types::GlobalIDType[::WorkItems::Type].coerce_isolated_input(global_id)
+
+ global_id&.model_id
+ end
+ end
+ end
+ end
+end
diff --git a/app/helpers/access_tokens_helper.rb b/app/helpers/access_tokens_helper.rb
index 1d38262159f..d8d44601327 100644
--- a/app/helpers/access_tokens_helper.rb
+++ b/app/helpers/access_tokens_helper.rb
@@ -27,4 +27,10 @@ module AccessTokensHelper
}
}.to_json
end
+
+ def expires_at_field_data
+ {}
+ end
end
+
+AccessTokensHelper.prepend_mod
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 5ca360f38da..cb43d911a2f 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -38,6 +38,8 @@ module AppearancesHelper
def brand_header_logo
if current_appearance&.header_logo?
image_tag current_appearance.header_logo_path, class: 'brand-header-logo'
+ elsif Feature.enabled?(:ukraine_support_tanuki)
+ render partial: 'shared/logo_ukraine', formats: :svg
else
render partial: 'shared/logo', formats: :svg
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index e675c01bcbb..feeedb0a501 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -18,6 +18,28 @@ module ApplicationHelper
end
end
+ def dispensable_render(...)
+ render(...)
+ rescue StandardError => error
+ if Feature.enabled?(:dispensable_render, default_enabled: :yaml)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error)
+ nil
+ else
+ raise error
+ end
+ end
+
+ def dispensable_render_if_exists(...)
+ render_if_exists(...)
+ rescue StandardError => error
+ if Feature.enabled?(:dispensable_render, default_enabled: :yaml)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error)
+ nil
+ else
+ raise error
+ end
+ end
+
def partial_exists?(partial)
lookup_context.exists?(partial, [], true)
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index fa9b3bfc912..a9c13b2fdeb 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -212,6 +212,7 @@ module ApplicationSettingsHelper
:auto_devops_enabled,
:auto_devops_domain,
:container_expiration_policies_enable_historic_entries,
+ :container_registry_expiration_policies_caching,
:container_registry_token_expire_delay,
:default_artifacts_expire_in,
:default_branch_name,
@@ -423,7 +424,8 @@ module ApplicationSettingsHelper
:sidekiq_job_limiter_compression_threshold_bytes,
:sidekiq_job_limiter_limit_bytes,
:suggest_pipeline_enabled,
- :user_email_lookup_limit,
+ :search_rate_limit,
+ :search_rate_limit_unauthenticated,
:users_get_by_id_limit,
:users_get_by_id_limit_allowlist_raw,
:runner_token_expiration_interval,
@@ -463,7 +465,10 @@ module ApplicationSettingsHelper
end
def instance_clusters_enabled?
- can?(current_user, :read_cluster, Clusters::Instance.new)
+ clusterable = Clusters::Instance.new
+
+ Feature.enabled?(:certificate_based_clusters, clusterable, default_enabled: :yaml, type: :ops) &&
+ can?(current_user, :read_cluster, clusterable)
end
def omnibus_protected_paths_throttle?
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index fb2fa547447..ba6c0380edf 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -178,7 +178,7 @@ module AuthHelper
end
def google_tag_manager_enabled?
- return false unless Gitlab.dev_env_or_com?
+ return false unless Gitlab.com?
if Feature.enabled?(:gtm_nonce, type: :ops)
extra_config.has_key?('google_tag_manager_nonce_id') &&
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index f0e8ff7778e..fcf6a177984 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -65,40 +65,13 @@ module BlobHelper
return unless blob = readable_blob(options, path, project, ref)
common_classes = "btn gl-button btn-confirm js-edit-blob gl-ml-3 #{options[:extra_class]}"
- data = { track_action: 'click_edit', track_label: 'edit' }
-
- if Feature.enabled?(:web_ide_primary_edit, project.group)
- common_classes += " btn-inverted"
- data[:track_property] = 'secondary'
- end
edit_button_tag(blob,
common_classes,
_('Edit'),
edit_blob_path(project, ref, path, options),
project,
- ref,
- data)
- end
-
- def ide_edit_button(project = @project, ref = @ref, path = @path, blob:)
- return unless blob
-
- common_classes = 'btn gl-button btn-confirm ide-edit-button gl-ml-3'
- data = { track_action: 'click_edit_ide', track_label: 'web_ide' }
-
- unless Feature.enabled?(:web_ide_primary_edit, project.group)
- common_classes += " btn-inverted"
- data[:track_property] = 'secondary'
- end
-
- edit_button_tag(blob,
- common_classes,
- _('Web IDE'),
- ide_edit_path(project, ref, path),
- project,
- ref,
- data)
+ ref)
end
def modify_file_button(project = @project, ref = @ref, path = @path, blob:, label:, action:, btn_class:, modal_type:)
@@ -363,16 +336,16 @@ module BlobHelper
content_tag(:span, button, class: 'has-tooltip', title: _('You can only edit files when you are on a branch'), data: { container: 'body' })
end
- def edit_link_tag(link_text, edit_path, common_classes, data)
- link_to link_text, edit_path, class: "#{common_classes}", data: data
+ def edit_link_tag(link_text, edit_path, common_classes)
+ link_to link_text, edit_path, class: "#{common_classes}"
end
- def edit_button_tag(blob, common_classes, text, edit_path, project, ref, data)
+ def edit_button_tag(blob, common_classes, text, edit_path, project, ref)
if !on_top_of_branch?(project, ref)
edit_disabled_button_tag(text, common_classes)
# This condition only applies to users who are logged in
elsif !current_user || (current_user && can_modify_blob?(blob, project, ref))
- edit_link_tag(text, edit_path, common_classes, data)
+ edit_link_tag(text, edit_path, common_classes)
elsif can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project)
edit_fork_button_tag(common_classes, project, text, edit_blob_fork_params(edit_path))
end
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index 881e11b10ea..dda834ee2c5 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -1,14 +1,22 @@
# frozen_string_literal: true
module BroadcastMessagesHelper
+ include Gitlab::Utils::StrongMemoize
+
def current_broadcast_banner_messages
- BroadcastMessage.current_banner_messages(request.path).select do |message|
+ BroadcastMessage.current_banner_messages(
+ current_path: request.path,
+ user_access_level: current_user_access_level_for_project_or_group
+ ).select do |message|
cookies["hide_broadcast_message_#{message.id}"].blank?
end
end
def current_broadcast_notification_message
- not_hidden_messages = BroadcastMessage.current_notification_messages(request.path).select do |message|
+ not_hidden_messages = BroadcastMessage.current_notification_messages(
+ current_path: request.path,
+ user_access_level: current_user_access_level_for_project_or_group
+ ).select do |message|
cookies["hide_broadcast_message_#{message.id}"].blank?
end
not_hidden_messages.last
@@ -61,4 +69,35 @@ module BroadcastMessagesHelper
def broadcast_type_options
BroadcastMessage.broadcast_types.keys.map { |w| [w.humanize, w] }
end
+
+ def target_access_level_options
+ BroadcastMessage::ALLOWED_TARGET_ACCESS_LEVELS.map do |access_level|
+ [Gitlab::Access.human_access(access_level), access_level]
+ end
+ end
+
+ def target_access_levels_display(access_levels)
+ access_levels.map do |access_level|
+ Gitlab::Access.human_access(access_level)
+ end.join(', ')
+ end
+
+ private
+
+ def current_user_access_level_for_project_or_group
+ return if Feature.disabled?(:role_targeted_broadcast_messages, default_enabled: :yaml)
+ return unless current_user.present?
+
+ strong_memoize(:current_user_access_level_for_project_or_group) do
+ if controller.is_a? Projects::ApplicationController
+ next unless @project
+
+ @project.team.max_member_access(current_user.id)
+ elsif controller.is_a? Groups::ApplicationController
+ next unless @group
+
+ @group.max_member_access_for_user(current_user)
+ end
+ end
+ end
end
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index c0dca66bac8..14e52b120f3 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -14,8 +14,7 @@ module Ci
"build_stage" => @build.stage,
"log_state" => '',
"build_options" => javascript_build_options,
- "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs'),
- "code_quality_help_url" => help_page_path('user/project/merge_requests/code_quality', anchor: 'troubleshooting')
+ "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs')
}
end
diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb
index 6104a1256d5..8d2f83409be 100644
--- a/app/helpers/ci/pipelines_helper.rb
+++ b/app/helpers/ci/pipelines_helper.rb
@@ -78,6 +78,37 @@ module Ci
pipeline.stuck?
end
+ def pipelines_list_data(project, list_url)
+ artifacts_endpoint_placeholder = ':pipeline_artifacts_id'
+
+ data = {
+ endpoint: list_url,
+ project_id: project.id,
+ default_branch_name: project.default_branch,
+ params: params.to_json,
+ artifacts_endpoint: downloadable_artifacts_project_pipeline_path(project, artifacts_endpoint_placeholder, format: :json),
+ artifacts_endpoint_placeholder: artifacts_endpoint_placeholder,
+ pipeline_schedule_url: pipeline_schedules_path(project),
+ empty_state_svg_path: image_path('illustrations/pipelines_empty.svg'),
+ error_state_svg_path: image_path('illustrations/pipelines_failed.svg'),
+ no_pipelines_svg_path: image_path('illustrations/pipelines_pending.svg'),
+ can_create_pipeline: can?(current_user, :create_pipeline, project).to_s,
+ new_pipeline_path: can?(current_user, :create_pipeline, project) && new_project_pipeline_path(project),
+ ci_lint_path: can?(current_user, :create_pipeline, project) && project_ci_lint_path(project),
+ reset_cache_path: can?(current_user, :admin_pipeline, project) && reset_cache_project_settings_ci_cd_path(project),
+ has_gitlab_ci: has_gitlab_ci?(project).to_s,
+ pipeline_editor_path: can?(current_user, :create_pipeline, project) && project_ci_pipeline_editor_path(project),
+ suggested_ci_templates: suggested_ci_templates.to_json,
+ ci_runner_settings_path: project_settings_ci_cd_path(project, ci_runner_templates: true, anchor: 'js-runners-settings')
+ }
+
+ experiment(:runners_availability_section, namespace: project.root_ancestor) do |e|
+ e.candidate { data[:any_runners_available] = project.active_runners.exists?.to_s }
+ end
+
+ data
+ end
+
private
def warning_markdown(pipeline)
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 1475a26ca09..959dac1254e 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -1,17 +1,6 @@
# frozen_string_literal: true
module ClustersHelper
- def create_new_cluster_label(provider: nil)
- case provider
- when 'aws'
- s_('ClusterIntegration|Create new cluster on EKS')
- when 'gcp'
- s_('ClusterIntegration|Create new cluster on GKE')
- else
- s_('ClusterIntegration|Create new cluster')
- end
- end
-
def display_cluster_agents?(clusterable)
clusterable.is_a?(Project)
end
@@ -26,22 +15,19 @@ module ClustersHelper
gcp: { path: image_path('illustrations/logos/google_gke.svg'), text: s_('ClusterIntegration|Google GKE') }
},
clusters_empty_state_image: image_path('illustrations/empty-state/empty-state-clusters.svg'),
+ empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg'),
empty_state_help_text: clusterable.empty_state_help_text,
- new_cluster_path: clusterable.new_path(tab: 'create'),
+ new_cluster_path: clusterable.new_path,
+ add_cluster_path: clusterable.connect_path,
can_add_cluster: clusterable.can_add_cluster?.to_s,
- can_admin_cluster: clusterable.can_admin_cluster?.to_s
- }
- end
-
- def js_clusters_data(clusterable)
- {
- default_branch_name: clusterable.default_branch,
- empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg'),
- project_path: clusterable.full_path,
- add_cluster_path: clusterable.new_path(tab: 'add'),
+ can_admin_cluster: clusterable.can_admin_cluster?.to_s,
+ display_cluster_agents: display_cluster_agents?(clusterable).to_s,
+ certificate_based_clusters_enabled: Feature.enabled?(:certificate_based_clusters, clusterable, default_enabled: :yaml, type: :ops).to_s,
+ default_branch_name: default_branch_name(clusterable),
+ project_path: clusterable_project_path(clusterable),
kas_address: Gitlab::Kas.external_url,
gitlab_version: Gitlab.version_info
- }.merge(js_clusters_list_data(clusterable))
+ }
end
def js_cluster_form_data(cluster, can_edit)
@@ -122,4 +108,14 @@ module ClustersHelper
def can_admin_cluster?(user, cluster)
can?(user, :admin_cluster, cluster)
end
+
+ private
+
+ def default_branch_name(clusterable)
+ clusterable.default_branch if clusterable.is_a?(Project)
+ end
+
+ def clusterable_project_path(clusterable)
+ clusterable.full_path if clusterable.is_a?(Project)
+ end
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 43e727ac483..c78e906e052 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -182,6 +182,19 @@ module CommitsHelper
project_commit_path(project, DEFAULT_SHA).sub("/#{DEFAULT_SHA}", '/$COMMIT_SHA')
end
+ def diff_mode_swap_button(mode, file_hash)
+ icon = mode == 'raw' ? 'doc-code' : 'doc-text'
+ entity = mode == 'raw' ? 'toHideBtn' : 'toShowBtn'
+ title = "Display #{mode} diff"
+
+ link_to("##{mode}-diff-#{file_hash}",
+ class: "btn gl-button btn-default btn-file-option has-tooltip btn-show-#{mode}-diff",
+ title: title,
+ data: { file_hash: file_hash, diff_toggle_entity: entity }) do
+ sprite_icon(icon)
+ end
+ end
+
protected
# Private: Returns a link to a person. If the person has a matching user and
diff --git a/app/helpers/container_expiration_policies_helper.rb b/app/helpers/container_expiration_policies_helper.rb
index 52f68ac53f0..0005682e979 100644
--- a/app/helpers/container_expiration_policies_helper.rb
+++ b/app/helpers/container_expiration_policies_helper.rb
@@ -25,8 +25,7 @@ module ContainerExpirationPoliciesHelper
end
end
- def container_expiration_policies_historic_entry_enabled?(project)
- Gitlab::CurrentSettings.container_expiration_policies_enable_historic_entries ||
- Feature.enabled?(:container_expiration_policies_historic_entry, project)
+ def container_expiration_policies_historic_entry_enabled?
+ Gitlab::CurrentSettings.container_expiration_policies_enable_historic_entries
end
end
diff --git a/app/helpers/container_registry_helper.rb b/app/helpers/container_registry_helper.rb
index 1b77b639ce1..255b8183164 100644
--- a/app/helpers/container_registry_helper.rb
+++ b/app/helpers/container_registry_helper.rb
@@ -2,8 +2,7 @@
module ContainerRegistryHelper
def container_registry_expiration_policies_throttling?
- Feature.enabled?(:container_registry_expiration_policies_throttling) &&
- ContainerRegistry::Client.supports_tag_delete?
+ Feature.enabled?(:container_registry_expiration_policies_throttling, default_enabled: :yaml)
end
def container_repository_gid_prefix
diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb
index f0e1f252917..bcb1f63840d 100644
--- a/app/helpers/dashboard_helper.rb
+++ b/app/helpers/dashboard_helper.rb
@@ -15,6 +15,10 @@ module DashboardHelper
merge_requests_dashboard_path(reviewer_username: current_user.username)
end
+ def attention_requested_mrs_dashboard_path
+ merge_requests_dashboard_path(attention: current_user.username)
+ end
+
def dashboard_nav_links
@dashboard_nav_links ||= get_dashboard_nav_links
end
diff --git a/app/helpers/deploy_tokens_helper.rb b/app/helpers/deploy_tokens_helper.rb
index d6fbe0b6b45..560d2fcd29f 100644
--- a/app/helpers/deploy_tokens_helper.rb
+++ b/app/helpers/deploy_tokens_helper.rb
@@ -16,4 +16,11 @@ module DeployTokensHelper
Gitlab.config.packages.enabled &&
can?(current_user, :read_package, group_or_project)
end
+
+ def deploy_token_revoke_button_data(token:, group_or_project:)
+ {
+ token: token.to_json(only: [:id, :name]),
+ revoke_path: revoke_deploy_token_path(group_or_project, token)
+ }
+ end
end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 2b5f726dad1..100d5c0281c 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -28,7 +28,7 @@ module DiffHelper
end
def diff_options
- options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded? }
+ options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded?, use_extra_viewer_as_main: true }
if action_name == 'diff_for_path'
options[:expanded] = true
@@ -74,7 +74,7 @@ module DiffHelper
end
def diff_link_number(line_type, match, text)
- line_type == match ? " " : text
+ line_type == match || text == 0 ? " " : text
end
def parallel_diff_discussions(left, right, diff_file)
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 0092743f96e..a910d3d7c9d 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -129,7 +129,7 @@ module DropdownsHelper
end
def dropdown_loading
- spinner = loading_icon(container: true, size: "md", css_class: "gl-mt-7")
+ spinner = gl_loading_icon(size: "md", css_class: "gl-mt-7")
content_tag(:div, spinner, class: "dropdown-loading")
end
end
diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb
index 026dbd60ac6..1defe480059 100644
--- a/app/helpers/explore_helper.rb
+++ b/app/helpers/explore_helper.rb
@@ -19,26 +19,10 @@ module ExploreHelper
request_path_with_options(options)
end
- def filter_audit_path(options = {})
- exist_opts = {
- entity_type: params[:entity_type],
- entity_id: params[:entity_id],
- created_before: params[:created_before],
- created_after: params[:created_after],
- sort: params[:sort]
- }
- options = exist_opts.merge(options).delete_if { |key, value| value.blank? }
- request_path_with_options(options)
- end
-
def filter_groups_path(options = {})
request_path_with_options(options)
end
- def explore_controller?
- controller.class.name.split("::").first == "Explore"
- end
-
def explore_nav_links
@explore_nav_links ||= get_explore_nav_links
end
@@ -47,14 +31,27 @@ module ExploreHelper
explore_nav_links.include?(link)
end
- def any_explore_nav_link?(links)
- links.any? { |link| explore_nav_link?(link) }
- end
-
def public_visibility_restricted?
Gitlab::VisibilityLevel.public_visibility_restricted?
end
+ def projects_filter_items
+ [
+ { value: _('Any'), text: _('Any'), href: filter_projects_path(visibility_level: nil) },
+ *Gitlab::VisibilityLevel.options.keys.map do |key|
+ {
+ value: key,
+ text: key,
+ href: filter_projects_path(visibility_level: Gitlab::VisibilityLevel.options[key])
+ }
+ end
+ ]
+ end
+
+ def projects_filter_selected(visibility_level)
+ visibility_level.present? ? visibility_level_label(visibility_level.to_i) : _('Any')
+ end
+
private
def get_explore_nav_links
diff --git a/app/helpers/groups/crm_settings_helper.rb b/app/helpers/groups/crm_settings_helper.rb
index ab47ec40b13..d7ca25a9d1b 100644
--- a/app/helpers/groups/crm_settings_helper.rb
+++ b/app/helpers/groups/crm_settings_helper.rb
@@ -2,7 +2,7 @@
module Groups
module CrmSettingsHelper
- def crm_feature_flag_enabled?(group)
+ def crm_feature_available?(group)
Feature.enabled?(:customer_relations, group)
end
end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index 07ab246b089..a719d80a1a1 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -9,10 +9,6 @@ module Groups::GroupMembersHelper
{ multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true }
end
- def render_invite_member_for_group(group, default_access_level)
- render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: group.access_level_roles, default_access_level: default_access_level
- end
-
def group_members_app_data(group, members:, invited:, access_requests:)
{
user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }),
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 32d808c960c..6f7ac069fe4 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -49,13 +49,39 @@ module IconsHelper
end
end
- def loading_icon(container: false, color: 'orange', size: 'sm', css_class: nil)
- css_classes = ['gl-spinner', "gl-spinner-#{color}", "gl-spinner-#{size}"]
- css_classes << "#{css_class}" unless css_class.blank?
-
- spinner = content_tag(:span, "", { class: css_classes.join(' '), aria: { label: _('Loading') } })
-
- container == true ? content_tag(:div, spinner, { class: 'gl-spinner-container' }) : spinner
+ # Creates a GitLab UI loading icon/spinner.
+ #
+ # Examples:
+ # # Default
+ # gl_loading_icon
+ #
+ # # Sizes
+ # gl_loading_icon(size: 'md')
+ # gl_loading_icon(size: 'lg')
+ # gl_loading_icon(size: 'xl')
+ #
+ # # Colors
+ # gl_loading_icon(color: 'light')
+ #
+ # # Block/Inline
+ # gl_loading_icon(inline: true)
+ #
+ # # Custom classes
+ # gl_loading_icon(css_class: "foo-bar")
+ #
+ # See also https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-loading-icon--default
+ def gl_loading_icon(inline: false, color: 'dark', size: 'sm', css_class: nil)
+ spinner = content_tag(:span, "", {
+ class: %[gl-spinner gl-spinner-#{color} gl-spinner-#{size} gl-vertical-align-text-bottom!],
+ aria: { label: _('Loading') }
+ })
+
+ container_classes = ['gl-spinner-container']
+ container_classes << css_class unless css_class.blank?
+ content_tag(inline ? :span : :div, spinner, {
+ class: container_classes,
+ role: 'status'
+ })
end
def external_snippet_icon(name)
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index f5ba978e860..b960ed46ba9 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -1,6 +1,35 @@
# frozen_string_literal: true
module IntegrationsHelper
+ def integration_event_title(event)
+ case event
+ when "push", "push_events"
+ _("Push")
+ when "tag_push", "tag_push_events"
+ _("Tag push")
+ when "note", "note_events"
+ _("Note")
+ when "confidential_note", "confidential_note_events"
+ _("Confidential note")
+ when "issue", "issue_events"
+ _("Issue")
+ when "confidential_issue", "confidential_issue_events"
+ _("Confidential issue")
+ when "merge_request", "merge_request_events"
+ _("Merge request")
+ when "pipeline", "pipeline_events"
+ _("Pipeline")
+ when "wiki_page", "wiki_page_events"
+ _("Wiki page")
+ when "commit", "commit_events"
+ _("Commit")
+ when "deployment"
+ _("Deployment")
+ when "alert"
+ _("Alert")
+ end
+ end
+
def integration_event_description(integration, event)
case integration
when Integrations::Jira
@@ -75,7 +104,8 @@ module IntegrationsHelper
form_data = {
id: integration.id,
show_active: integration.show_active_box?.to_s,
- activated: (integration.active || integration.new_record?).to_s,
+ activated: (integration.active || (integration.new_record? && integration.activate_disabled_reason.nil?)).to_s,
+ activate_disabled: integration.activate_disabled_reason.present?.to_s,
type: integration.to_param,
merge_request_events: integration.merge_requests_events.to_s,
commit_events: integration.commit_events.to_s,
@@ -83,6 +113,7 @@ module IntegrationsHelper
comment_detail: integration.comment_detail,
learn_more_path: integrations_help_page_path,
trigger_events: trigger_events_for_integration(integration),
+ sections: integration.sections.to_json,
fields: fields_for_integration(integration),
inherit_from_id: integration.inherit_from_id,
integration_level: integration_level(integration),
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
index 1f225e9c0e5..a2dde29e25d 100644
--- a/app/helpers/invite_members_helper.rb
+++ b/app/helpers/invite_members_helper.rb
@@ -6,7 +6,7 @@ module InviteMembersHelper
def can_invite_members_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, default_enabled: :yaml) && can?(current_user, :admin_project_member, project)
+ can?(current_user, :admin_project_member, project)
end
def invite_accepted_notice(member)
@@ -73,7 +73,7 @@ module InviteMembersHelper
def show_invite_members_for_task?(source)
return unless current_user
- invite_for_help_continuous_onboarding = source.is_a?(Project) && experiment(:invite_for_help_continuous_onboarding, namespace: source.namespace).variant.name == 'candidate'
+ invite_for_help_continuous_onboarding = source.is_a?(Project) && experiment(:invite_for_help_continuous_onboarding, namespace: source.namespace).assigned.name == 'candidate'
params[:open_modal] == 'invite_members_for_task' || invite_for_help_continuous_onboarding
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 8e7f5060412..298162fe970 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -169,7 +169,7 @@ module IssuesHelper
end
def issue_header_actions_data(project, issuable, current_user)
- new_issuable_params = { issue: { description: _('Related to #%{issue_id}.') % { issue_id: issuable.iid } + "\n\n" } }
+ new_issuable_params = { issue: {}, add_related_issue: issuable.iid }
if issuable.incident?
new_issuable_params[:issuable_template] = 'incident'
new_issuable_params[:issue][:issue_type] = 'incident'
@@ -209,7 +209,7 @@ module IssuesHelper
}
end
- def project_issues_list_data(project, current_user, finder)
+ def project_issues_list_data(project, current_user)
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,
@@ -223,7 +223,7 @@ module IssuesHelper
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) }),
+ new_issue_path: new_project_issue_path(project),
project_import_jira_path: project_import_jira_path(project),
quick_actions_help_path: help_page_path('user/project/quick_actions'),
releases_path: project_releases_path(project, format: :json),
diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb
index 9a0f0944fd1..67b85b26f9e 100644
--- a/app/helpers/jira_connect_helper.rb
+++ b/app/helpers/jira_connect_helper.rb
@@ -9,12 +9,38 @@ module JiraConnectHelper
subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json,
subscriptions_path: jira_connect_subscriptions_path,
users_path: current_user ? nil : jira_connect_users_path, # users_path is used to determine if user is signed in
- gitlab_user_path: current_user ? user_path(current_user) : nil
+ gitlab_user_path: current_user ? user_path(current_user) : nil,
+ oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data.to_json : nil
}
end
private
+ def jira_connect_oauth_data
+ oauth_authorize_url = oauth_authorization_url(
+ client_id: ENV['JIRA_CONNECT_OAUTH_CLIENT_ID'],
+ response_type: 'code',
+ scope: 'api',
+ redirect_uri: jira_connect_oauth_callbacks_url,
+ state: oauth_state
+ )
+
+ {
+ oauth_authorize_url: oauth_authorize_url,
+ oauth_token_url: oauth_token_url,
+ state: oauth_state,
+ oauth_token_payload: {
+ grant_type: :authorization_code,
+ client_id: ENV['JIRA_CONNECT_OAUTH_CLIENT_ID'],
+ redirect_uri: jira_connect_oauth_callbacks_url
+ }
+ }
+ end
+
+ def oauth_state
+ @oauth_state ||= SecureRandom.hex(32)
+ end
+
def serialize_subscription(subscription)
{
group: {
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index 2150729cb2a..877785c9eaf 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -61,7 +61,7 @@ module LabelsHelper
render_label_text(
label.name,
suffix: suffix,
- css_class: "gl-label-text #{text_color_class_for_bg(label.color)}",
+ css_class: "gl-label-text #{label.text_color_class}",
bg_color: label.color
)
end
@@ -114,30 +114,8 @@ module LabelsHelper
end
end
- def text_color_class_for_bg(bg_color)
- if light_color?(bg_color)
- 'gl-label-text-dark'
- else
- 'gl-label-text-light'
- end
- end
-
def text_color_for_bg(bg_color)
- if light_color?(bg_color)
- '#333333'
- else
- '#FFFFFF'
- end
- end
-
- def light_color?(color)
- if color.length == 4
- r, g, b = color[1, 4].scan(/./).map { |v| (v * 2).hex }
- else
- r, g, b = color[1, 7].scan(/.{2}/).map(&:hex)
- end
-
- (r + g + b) > 500
+ ::Gitlab::Color.of(bg_color).contrast
end
def labels_filter_path_with_defaults(only_group_labels: false, include_ancestor_groups: true, include_descendant_groups: false)
diff --git a/app/helpers/lazy_image_tag_helper.rb b/app/helpers/lazy_image_tag_helper.rb
index 0c5744b46ae..d0bdaaae5f8 100644
--- a/app/helpers/lazy_image_tag_helper.rb
+++ b/app/helpers/lazy_image_tag_helper.rb
@@ -1,12 +1,15 @@
# frozen_string_literal: true
module LazyImageTagHelper
+ include PreferencesHelper
+
def placeholder_image
"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
end
# Override the default ActionView `image_tag` helper to support lazy-loading
def image_tag(source, options = {})
+ source = options[:dark_variant] if options[:dark_variant] && user_application_dark_mode?
options = options.symbolize_keys
unless options.delete(:lazy) == false
diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb
index 7dfd9ed47e3..60f3b12d736 100644
--- a/app/helpers/learn_gitlab_helper.rb
+++ b/app/helpers/learn_gitlab_helper.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
module LearnGitlabHelper
+ IMAGE_PATH_PLAN = "learn_gitlab/section_plan.svg"
+ IMAGE_PATH_DEPLOY = "learn_gitlab/section_deploy.svg"
+ IMAGE_PATH_WORKSPACE = "learn_gitlab/section_workspace.svg"
+
def learn_gitlab_enabled?(project)
return false unless current_user
@@ -25,19 +29,7 @@ module LearnGitlabHelper
def onboarding_actions_data(project)
attributes = onboarding_progress(project).attributes.symbolize_keys
- urls_to_use = nil
-
- experiment(
- :change_continuous_onboarding_link_urls,
- namespace: project.namespace,
- actor: current_user,
- sticky_to: project.namespace
- ) do |e|
- e.control { urls_to_use = action_urls }
- e.candidate { urls_to_use = new_action_urls(project) }
- end
-
- urls_to_use.to_h do |action, url|
+ action_urls(project).to_h do |action, url|
[
action,
url: url,
@@ -50,13 +42,13 @@ module LearnGitlabHelper
def onboarding_sections_data
{
workspace: {
- svg: image_path("learn_gitlab/section_workspace.svg")
+ svg: image_path(IMAGE_PATH_WORKSPACE)
},
plan: {
- svg: image_path("learn_gitlab/section_plan.svg")
+ svg: image_path(IMAGE_PATH_PLAN)
},
deploy: {
- svg: image_path("learn_gitlab/section_deploy.svg")
+ svg: image_path(IMAGE_PATH_DEPLOY)
}
}
end
@@ -65,22 +57,20 @@ module LearnGitlabHelper
{ name: project.name }
end
- def action_urls
- LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) }
- .merge(LearnGitlab::Onboarding::ACTION_DOC_URLS)
- end
-
- def new_action_urls(project)
- action_urls.merge(
+ def action_urls(project)
+ action_issue_urls.merge(
issue_created: project_issues_path(project),
git_write: project_path(project),
- pipeline_created: project_pipelines_path(project),
merge_request_created: project_merge_requests_path(project),
user_added: project_members_url(project),
security_scan_enabled: project_security_configuration_path(project)
)
end
+ def action_issue_urls
+ LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) }
+ end
+
def learn_gitlab_project
@learn_gitlab_project ||= LearnGitlab::Project.new(current_user).project
end
diff --git a/app/helpers/listbox_helper.rb b/app/helpers/listbox_helper.rb
index d24680bc0b0..16caf862c7b 100644
--- a/app/helpers/listbox_helper.rb
+++ b/app/helpers/listbox_helper.rb
@@ -16,8 +16,10 @@ module ListboxHelper
# the sort key), `text` is the user-facing string for the item, and `href` is
# the path to redirect to when that item is selected.
#
- # The `selected` parameter is the currently selected `value`, and must
- # correspond to one of the `items`, or be `nil`. When `selected.nil?`, the first item is selected.
+ # The `selected` parameter is the currently selected `value`, and should
+ # correspond to one of the `items`, or be `nil`. When `selected.nil?` or
+ # a value which does not correspond to one of the items, the first item is
+ # selected.
#
# The final parameter `html_options` applies arbitrary attributes to the
# returned tag. Some of these are passed to the underlying Vue component as
@@ -37,9 +39,12 @@ module ListboxHelper
webpack_bundle_tag 'redirect_listbox'
end
- selected ||= items.first[:value]
selected_option = items.find { |opt| opt[:value] == selected }
- raise ArgumentError, "cannot find #{selected} in #{items}" unless selected_option
+
+ unless selected_option
+ selected_option = items.first
+ selected = selected_option[:value]
+ end
button = button_tag(type: :button, class: DROPDOWN_BUTTON_CLASSES) do
content_tag(:span, selected_option[:text], class: DROPDOWN_INNER_CLASS) +
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index f16d9f6325b..7a4cc61af79 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -127,7 +127,7 @@ module MarkupHelper
text = wiki_page.content
return '' unless text.present?
- context = render_wiki_content_context(@wiki, wiki_page, context)
+ context = render_wiki_content_context(wiki_page.wiki, wiki_page, context)
html = markup_unsafe(wiki_page.path, text, context)
prepare_for_rendering(html, context)
@@ -181,7 +181,8 @@ module MarkupHelper
wiki: wiki,
repository: wiki.repository,
page_slug: wiki_page.slug,
- issuable_reference_expansion_enabled: true
+ issuable_reference_expansion_enabled: true,
+ requested_path: wiki_page.path
).merge(render_wiki_content_context_container(wiki))
end
@@ -263,7 +264,7 @@ module MarkupHelper
end
def asciidoc_unsafe(text, context = {})
- context.merge!(
+ context.reverse_merge!(
commit: @commit,
ref: @ref,
requested_path: @path
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index abb7128470f..84a3802c72c 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -150,11 +150,20 @@ module MergeRequestsHelper
review_requested_count = review_requested_merge_requests_count
total_count = assigned_count + review_requested_count
- {
+ counts = {
assigned: assigned_count,
review_requested: review_requested_count,
total: total_count
}
+
+ if Feature.enabled?(:mr_attention_requests, default_enabled: :yaml)
+ attention_requested_count = attention_requested_merge_requests_count
+
+ counts[:attention_requested_count] = attention_requested_count
+ counts[:total] = attention_requested_count
+ end
+
+ counts
end
end
@@ -205,6 +214,10 @@ module MergeRequestsHelper
current_user.review_requested_open_merge_requests_count
end
+ def attention_requested_merge_requests_count
+ current_user.attention_requested_open_merge_requests_count
+ end
+
def default_suggestion_commit_message
@project.suggestion_commit_message.presence || Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE
end
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index 402a363349f..01075862618 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -50,8 +50,6 @@ module PackagesHelper
Gitlab.com? &&
Gitlab.config.registry.enabled &&
project.feature_available?(:container_registry, current_user) &&
- !Gitlab::CurrentSettings.container_expiration_policies_enable_historic_entries &&
- Feature.enabled?(:container_expiration_policies_historic_entry, project) &&
project.container_expiration_policy.nil? &&
project.container_repositories.exists?
end
diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb
index 3167142e193..88bf09f0c03 100644
--- a/app/helpers/pagination_helper.rb
+++ b/app/helpers/pagination_helper.rb
@@ -22,4 +22,8 @@ module PaginationHelper
def paginate_with_count(collection, remote: nil, total_pages: nil)
paginate(collection, remote: remote, theme: 'gitlab', total_pages: total_pages)
end
+
+ def page_size
+ Kaminari.config.default_per_page
+ end
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 17450e5b26b..6a8c39b5b15 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -62,6 +62,10 @@ module PreferencesHelper
@user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class
end
+ def user_application_dark_mode?
+ user_application_theme == 'gl-dark'
+ end
+
def user_application_theme_css_filename
@user_application_theme_css_filename ||= Gitlab::Themes.for_user(current_user).css_filename
end
diff --git a/app/helpers/projects/cluster_agents_helper.rb b/app/helpers/projects/cluster_agents_helper.rb
index 43d520d0eab..c17cb787c9f 100644
--- a/app/helpers/projects/cluster_agents_helper.rb
+++ b/app/helpers/projects/cluster_agents_helper.rb
@@ -7,7 +7,9 @@ module Projects::ClusterAgentsHelper
agent_name: agent_name,
can_admin_vulnerability: can?(current_user, :admin_vulnerability, project).to_s,
empty_state_svg_path: image_path('illustrations/operations-dashboard_empty.svg'),
- project_path: project.full_path
+ project_path: project.full_path,
+ kas_address: Gitlab::Kas.external_url,
+ can_admin_cluster: can?(current_user, :admin_cluster, project).to_s
}
end
end
diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb
index 5be4f67bde8..471565d162c 100644
--- a/app/helpers/projects/error_tracking_helper.rb
+++ b/app/helpers/projects/error_tracking_helper.rb
@@ -12,7 +12,8 @@ module Projects::ErrorTrackingHelper
'error-tracking-enabled' => error_tracking_enabled.to_s,
'project-path' => project.full_path,
'list-path' => project_error_tracking_index_path(project),
- 'illustration-path' => image_path('illustrations/cluster_popover.svg')
+ 'illustration-path' => image_path('illustrations/cluster_popover.svg'),
+ 'show-integrated-tracking-disabled-alert' => show_integrated_tracking_disabled_alert?(project).to_s
}
end
@@ -27,4 +28,15 @@ module Projects::ErrorTrackingHelper
'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts)
}
end
+
+ private
+
+ def show_integrated_tracking_disabled_alert?(project)
+ return false if ::Feature.enabled?(:integrated_error_tracking, project)
+
+ setting ||= project.error_tracking_setting ||
+ project.build_error_tracking_setting
+
+ setting.integrated_enabled?
+ end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 6098ef63ec3..8a75f545a32 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -160,7 +160,7 @@ module ProjectsHelper
end
def link_to_autodeploy_doc
- link_to _('About auto deploy'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank'
+ link_to _('About auto deploy'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank', rel: 'noopener'
end
def autodeploy_flash_notice(branch_name)
@@ -431,19 +431,26 @@ module ProjectsHelper
end
def import_from_bitbucket_message
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/bitbucket") }
+ configure_oauth_import_message('Bitbucket', help_page_path("integration/bitbucket"))
+ end
+
+ def import_from_gitlab_message
+ configure_oauth_import_message('GitLab.com', help_page_path("integration/gitlab"))
+ end
+ private
+
+ def configure_oauth_import_message(provider, help_url)
str = if current_user.admin?
- 'ImportProjects|To enable importing projects from Bitbucket, as administrator you need to configure %{link_start}OAuth integration%{link_end}'
+ 'ImportProjects|To enable importing projects from %{provider}, as administrator you need to configure %{link_start}OAuth integration%{link_end}'
else
- 'ImportProjects|To enable importing projects from Bitbucket, ask your GitLab administrator to configure %{link_start}OAuth integration%{link_end}'
+ 'ImportProjects|To enable importing projects from %{provider}, ask your GitLab administrator to configure %{link_start}OAuth integration%{link_end}'
end
- s_(str).html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_url }
+ s_(str).html_safe % { provider: provider, link_start: link_start, link_end: '</a>'.html_safe }
end
- private
-
def tab_ability_map
{
cycle_analytics: :read_cycle_analytics,
diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb
index fd9907edc37..f1fafd563ce 100644
--- a/app/helpers/routing/pseudonymization_helper.rb
+++ b/app/helpers/routing/pseudonymization_helper.rb
@@ -15,7 +15,7 @@ module Routing
end
def mask_params
- return default_root_url + @request.original_fullpath unless has_maskable_params?
+ return @request.original_url unless has_maskable_params?
masked_params = @request.path_parameters.to_h do |key, value|
case key
@@ -66,10 +66,6 @@ module Routing
query_string_hash
end
-
- def default_root_url
- Gitlab::Routing.url_helpers.root_url(only_path: false)
- end
end
def masked_page_url(group:, project:)
diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb
index e9466a9e97e..f0389000eb3 100644
--- a/app/helpers/sessions_helper.rb
+++ b/app/helpers/sessions_helper.rb
@@ -5,7 +5,7 @@ module SessionsHelper
def recently_confirmed_com?
strong_memoize(:recently_confirmed_com) do
- ::Gitlab.dev_env_or_com? &&
+ ::Gitlab.com? &&
!!flash[:notice]&.include?(t(:confirmed, scope: [:devise, :confirmations]))
end
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index fb30e8ca059..4db14d5cc4d 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -328,6 +328,16 @@ module SortingHelper
sort_direction_button(url, reverse_sort, sort_value)
end
+
+ def admin_users_sort_options(path_params)
+ users_sort_options_hash.map do |value, text|
+ {
+ value: value,
+ text: text,
+ href: admin_users_path(sort: value, **path_params)
+ }
+ end
+ end
end
SortingHelper.prepend_mod_with('SortingHelper')
diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb
index 34ba66db444..a075ccc38f5 100644
--- a/app/helpers/storage_helper.rb
+++ b/app/helpers/storage_helper.rb
@@ -25,16 +25,17 @@ module StorageHelper
end
def storage_enforcement_banner_info(namespace)
+ return unless can?(current_user, :admin_namespace, namespace)
return if namespace.paid?
return unless namespace.storage_enforcement_date && namespace.storage_enforcement_date >= Date.today
return if user_dismissed_storage_enforcement_banner?(namespace)
{
text: html_escape_once(s_("UsageQuota|From %{storage_enforcement_date} storage limits will apply to this namespace. " \
- "View and manage your usage in %{strong_start}Group Settings &gt; Usage quotas%{strong_end}.")).html_safe %
- { storage_enforcement_date: namespace.storage_enforcement_date, strong_start: "<strong>".html_safe, strong_end: "</strong>".html_safe },
+ "View and manage your usage in %{strong_start}%{namespace_type} settings &gt; Usage quotas%{strong_end}.")).html_safe %
+ { storage_enforcement_date: namespace.storage_enforcement_date, strong_start: "<strong>".html_safe, strong_end: "</strong>".html_safe, namespace_type: namespace.type },
variant: 'warning',
- callouts_path: group_callouts_path,
+ callouts_path: namespace.user_namespace? ? callouts_path : group_callouts_path,
callouts_feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace),
learn_more_link: link_to(_('Learn more.'), help_page_path('/'), rel: 'noopener noreferrer', target: '_blank') # TBD: https://gitlab.com/gitlab-org/gitlab/-/issues/350632
}
@@ -52,13 +53,17 @@ module StorageHelper
return :first if days_to_enforcement_date > 30
return :second if days_to_enforcement_date > 15 && days_to_enforcement_date <= 30
return :third if days_to_enforcement_date > 7 && days_to_enforcement_date <= 15
- return :fourth if days_to_enforcement_date > 0 && days_to_enforcement_date <= 7
+ return :fourth if days_to_enforcement_date >= 0 && days_to_enforcement_date <= 7
end
def user_dismissed_storage_enforcement_banner?(namespace)
return false unless current_user
- current_user.dismissed_callout_for_group?(feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace),
- group: namespace)
+ if namespace.user_namespace?
+ current_user.dismissed_callout?(feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace))
+ else
+ current_user.dismissed_callout_for_group?(feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace),
+ group: namespace)
+ end
end
end
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index f1e0be3a622..d3af6a00181 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -32,7 +32,7 @@ module SubmoduleHelper
namespace.sub!(%r{\A/}, '')
project.rstrip!
- project.sub!(/\.git\z/, '')
+ project.delete_suffix!('.git')
if self_url?(url, namespace, project)
[
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 79767ca76b7..60bf79f3114 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -18,7 +18,7 @@ module TodosHelper
when Todo::ASSIGNED then todo.self_added? ? 'assigned' : 'assigned you'
when Todo::REVIEW_REQUESTED then 'requested a review of'
when Todo::MENTIONED then "mentioned #{todo_action_subject(todo)} on"
- when Todo::BUILD_FAILED then 'The build failed for'
+ when Todo::BUILD_FAILED then 'The pipeline failed in'
when Todo::MARKED then 'added a todo for'
when Todo::APPROVAL_REQUIRED then "set #{todo_action_subject(todo)} as an approver for"
when Todo::UNMERGEABLE then 'Could not merge'
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 23a9601aed7..2fef4ae98a9 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -203,9 +203,11 @@ module TreeHelper
show_edit_button: show_edit_button?(options),
show_web_ide_button: show_web_ide_button?,
show_gitpod_button: show_gitpod_button?,
+ show_pipeline_editor_button: show_pipeline_editor_button?(@project, @path),
web_ide_url: web_ide_url,
edit_url: edit_url(options),
+ pipeline_editor_url: project_ci_pipeline_editor_path(@project, branch_name: @ref),
gitpod_url: gitpod_url,
user_preferences_gitpod_path: profile_preferences_path(anchor: 'user_gitpod_enabled'),
diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb
index 32b0d7b3fe3..87c8bf5cb28 100644
--- a/app/helpers/users/callouts_helper.rb
+++ b/app/helpers/users/callouts_helper.rb
@@ -10,6 +10,7 @@ module Users
REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout'
UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout'
SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout'
+ REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze
def show_gke_cluster_integration_callout?(project)
active_nav_link?(controller: sidebar_operations_paths) &&
@@ -47,7 +48,8 @@ module Users
!Gitlab.com? &&
current_user&.admin? &&
signup_enabled? &&
- !user_dismissed?(REGISTRATION_ENABLED_CALLOUT)
+ !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) &&
+ REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS.any? { |path| controller.controller_path.match?(path) }
end
def dismiss_two_factor_auth_recovery_settings_check
diff --git a/app/helpers/web_ide_button_helper.rb b/app/helpers/web_ide_button_helper.rb
index 6c73d365e8e..9ec22a659d3 100644
--- a/app/helpers/web_ide_button_helper.rb
+++ b/app/helpers/web_ide_button_helper.rb
@@ -29,6 +29,10 @@ module WebIdeButtonHelper
show_web_ide_button? && Gitlab::CurrentSettings.gitpod_enabled
end
+ def show_pipeline_editor_button?(project, path)
+ can_view_pipeline_editor?(project) && path == project.ci_config_path_or_default
+ end
+
def can_push_code?
current_user&.can?(:push_code, @project)
end
diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb
index ccccfcb930b..bbf56c51c6d 100644
--- a/app/helpers/whats_new_helper.rb
+++ b/app/helpers/whats_new_helper.rb
@@ -10,7 +10,7 @@ module WhatsNewHelper
end
def display_whats_new?
- (Gitlab.dev_env_org_or_com? || user_signed_in?) &&
+ (Gitlab.org_or_com? || user_signed_in?) &&
!Gitlab::CurrentSettings.current_application_settings.whats_new_variant_disabled?
end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index e0c95370072..94ed83a7d4a 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -7,6 +7,7 @@ class ApplicationMailer < ActionMailer::Base
helper MarkupHelper
attr_accessor :current_user
+
helper_method :current_user, :can?
default from: proc { default_sender_address.format }
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 592c394bb48..28e51ba311b 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -58,6 +58,18 @@ module Emails
end
# rubocop: enable CodeReuse/ActiveRecord
+ def access_token_created_email(user, token_name)
+ return unless user&.active?
+
+ @user = user
+ @target_url = profile_personal_access_tokens_url
+ @token_name = token_name
+
+ Gitlab::I18n.with_locale(@user.preferred_language) do
+ mail(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created")))
+ end
+ end
+
def access_token_about_to_expire_email(user, token_names)
return unless user
diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb
new file mode 100644
index 00000000000..44d2dc369f7
--- /dev/null
+++ b/app/models/analytics/cycle_analytics/aggregation.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+class Analytics::CycleAnalytics::Aggregation < ApplicationRecord
+ include FromUnion
+
+ belongs_to :group, optional: false
+
+ validates :incremental_runtimes_in_seconds, :incremental_processed_records, :last_full_run_runtimes_in_seconds, :last_full_run_processed_records, presence: true, length: { maximum: 10 }, allow_blank: true
+
+ scope :priority_order, -> (column_to_sort = :last_incremental_run_at) { order(arel_table[column_to_sort].asc.nulls_first) }
+ scope :enabled, -> { where('enabled IS TRUE') }
+
+ def estimated_next_run_at
+ return unless enabled
+ return if last_incremental_run_at.nil?
+
+ estimation = duration_until_the_next_aggregation_job +
+ average_aggregation_duration +
+ (last_incremental_run_at - earliest_last_run_at)
+
+ estimation < 1 ? nil : estimation.from_now
+ end
+
+ def self.safe_create_for_group(group)
+ top_level_group = group.root_ancestor
+ aggregation = find_by(group_id: top_level_group.id)
+ return aggregation if aggregation.present?
+
+ insert({ group_id: top_level_group.id }, unique_by: :group_id)
+ find_by(group_id: top_level_group.id)
+ end
+
+ private
+
+ # The aggregation job is scheduled every 10 minutes: */10 * * * *
+ def duration_until_the_next_aggregation_job
+ (10 - (DateTime.current.minute % 10)).minutes.seconds
+ end
+
+ def average_aggregation_duration
+ return 0.seconds if incremental_runtimes_in_seconds.empty?
+
+ average = incremental_runtimes_in_seconds.sum.fdiv(incremental_runtimes_in_seconds.size)
+ average.seconds
+ end
+
+ def earliest_last_run_at
+ max = self.class.select(:last_incremental_run_at)
+ .where(enabled: true)
+ .where.not(last_incremental_run_at: nil)
+ .priority_order
+ .limit(1)
+ .to_sql
+
+ connection.select_value("(#{max})")
+ end
+
+ def self.load_batch(last_run_at, column_to_query = :last_incremental_run_at, batch_size = 100)
+ last_run_at_not_set = Analytics::CycleAnalytics::Aggregation
+ .enabled
+ .where(column_to_query => nil)
+ .priority_order(column_to_query)
+ .limit(batch_size)
+
+ last_run_at_before = Analytics::CycleAnalytics::Aggregation
+ .enabled
+ .where(arel_table[column_to_query].lt(last_run_at))
+ .priority_order(column_to_query)
+ .limit(batch_size)
+
+ Analytics::CycleAnalytics::Aggregation
+ .from_union([last_run_at_not_set, last_run_at_before], remove_order: false, remove_duplicates: false)
+ .limit(batch_size)
+ end
+end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 06ff18ca409..198a3653cd3 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -5,6 +5,7 @@ class ApplicationRecord < ActiveRecord::Base
include Transactions
include LegacyBulkInsert
include CrossDatabaseModification
+ include SensitiveSerializableHash
self.abstract_class = true
@@ -60,8 +61,10 @@ class ApplicationRecord < ActiveRecord::Base
end
# Start a new transaction with a shorter-than-usual statement timeout. This is
- # currently one third of the default 15-second timeout
- def self.with_fast_read_statement_timeout(timeout_ms = 5000)
+ # currently one third of the default 15-second timeout with a 500ms buffer
+ # to allow callers gracefully handling the errors to still complete within
+ # the 5s target duration of a low urgency request.
+ def self.with_fast_read_statement_timeout(timeout_ms = 4500)
::Gitlab::Database::LoadBalancing::Session.current.fallback_to_replicas_for_ambiguous_queries do
transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
connection.exec_query("SET LOCAL statement_timeout = #{timeout_ms}")
@@ -99,6 +102,10 @@ class ApplicationRecord < ActiveRecord::Base
where('EXISTS (?)', query.select(1))
end
+ def self.where_not_exists(query)
+ where('NOT EXISTS (?)', query.select(1))
+ end
+
def self.declarative_enum(enum_mod)
enum(enum_mod.key => enum_mod.values)
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 02fbf0f855e..c7aad7ff861 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -11,6 +11,7 @@ class ApplicationSetting < ApplicationRecord
ignore_columns %i[elasticsearch_shards elasticsearch_replicas], remove_with: '14.4', remove_after: '2021-09-22'
ignore_columns %i[static_objects_external_storage_auth_token], remove_with: '14.9', remove_after: '2022-03-22'
ignore_column %i[max_package_files_for_package_destruction], remove_with: '14.9', remove_after: '2022-03-22'
+ ignore_column :user_email_lookup_limit, remove_with: '15.0', remove_after: '2022-04-18'
INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
@@ -362,6 +363,9 @@ class ApplicationSetting < ApplicationRecord
:container_registry_expiration_policies_worker_capacity,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :container_registry_expiration_policies_caching,
+ inclusion: { in: [true, false], message: _('must be a boolean value') }
+
validates :container_registry_import_max_tags_count,
:container_registry_import_max_retries,
:container_registry_import_start_max_retries,
@@ -516,9 +520,12 @@ class ApplicationSetting < ApplicationRecord
validates :notes_create_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
- validates :user_email_lookup_limit,
+ validates :search_rate_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :search_rate_limit_unauthenticated,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
validates :notes_create_limit_allowlist,
length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
allow_nil: false
@@ -650,7 +657,17 @@ class ApplicationSetting < ApplicationRecord
users_count >= INSTANCE_REVIEW_MIN_USERS
end
+ Recursion = Class.new(RuntimeError)
+
def self.create_from_defaults
+ # this is posssible if calls to create the record depend on application
+ # settings themselves. This was seen in the case of a feature flag called by
+ # `transaction` that ended up requiring application settings to determine metrics behavior.
+ # If something like that happens, we break the loop here, and let the caller decide how to manage it.
+ raise Recursion if Thread.current[:application_setting_create_from_defaults]
+
+ Thread.current[:application_setting_create_from_defaults] = true
+
check_schema!
transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
@@ -659,6 +676,8 @@ class ApplicationSetting < ApplicationRecord
rescue ActiveRecord::RecordNotUnique
# We already have an ApplicationSetting record, so just return it.
current_without_cache
+ ensure
+ Thread.current[:application_setting_create_from_defaults] = nil
end
def self.find_or_create_without_cache
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 415f0b35f3a..42049713883 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -218,7 +218,9 @@ module ApplicationSettingImplementation
valid_runner_registrars: VALID_RUNNER_REGISTRAR_TYPES,
wiki_page_max_content_bytes: 50.megabytes,
container_registry_delete_tags_service_timeout: 250,
- container_registry_expiration_policies_worker_capacity: 0,
+ container_registry_expiration_policies_worker_capacity: 4,
+ container_registry_cleanup_tags_service_max_list_size: 200,
+ container_registry_expiration_policies_caching: true,
container_registry_import_max_tags_count: 100,
container_registry_import_max_retries: 3,
container_registry_import_start_max_retries: 50,
@@ -231,7 +233,8 @@ module ApplicationSettingImplementation
rate_limiting_response_text: nil,
whats_new_variant: 0,
user_deactivation_emails_enabled: true,
- user_email_lookup_limit: 60,
+ search_rate_limit: 30,
+ search_rate_limit_unauthenticated: 10,
users_get_by_id_limit: 300,
users_get_by_id_limit_allowlist: []
}
@@ -402,7 +405,7 @@ module ApplicationSettingImplementation
def normalized_repository_storage_weights
strong_memoize(:normalized_repository_storage_weights) do
repository_storages_weights = repository_storages_weighted.slice(*Gitlab.config.repositories.storages.keys)
- weights_total = repository_storages_weights.values.reduce(:+)
+ weights_total = repository_storages_weights.values.sum
repository_storages_weights.transform_values do |w|
next w if weights_total == 0
diff --git a/app/models/blobs/notebook.rb b/app/models/blobs/notebook.rb
new file mode 100644
index 00000000000..bdb438cccd9
--- /dev/null
+++ b/app/models/blobs/notebook.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Blobs
+ class Notebook < ::Blob
+ attr_reader :data
+
+ def initialize(blob, data)
+ super(blob.__getobj__, blob.container)
+ @data = data
+ end
+ end
+end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 1ee5c081840..949902fbb77 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -4,12 +4,21 @@ class BroadcastMessage < ApplicationRecord
include CacheMarkdownField
include Sortable
+ ALLOWED_TARGET_ACCESS_LEVELS = [
+ Gitlab::Access::GUEST,
+ Gitlab::Access::REPORTER,
+ Gitlab::Access::DEVELOPER,
+ Gitlab::Access::MAINTAINER,
+ Gitlab::Access::OWNER
+ ].freeze
+
cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true
validates :message, presence: true
validates :starts_at, presence: true
validates :ends_at, presence: true
validates :broadcast_type, presence: true
+ validates :target_access_levels, inclusion: { in: ALLOWED_TARGET_ACCESS_LEVELS }
validates :color, allow_blank: true, color: true
validates :font, allow_blank: true, color: true
@@ -29,20 +38,20 @@ class BroadcastMessage < ApplicationRecord
}
class << self
- def current_banner_messages(current_path = nil)
- fetch_messages BANNER_CACHE_KEY, current_path do
+ def current_banner_messages(current_path: nil, user_access_level: nil)
+ fetch_messages BANNER_CACHE_KEY, current_path, user_access_level do
current_and_future_messages.banner
end
end
- def current_notification_messages(current_path = nil)
- fetch_messages NOTIFICATION_CACHE_KEY, current_path do
+ def current_notification_messages(current_path: nil, user_access_level: nil)
+ fetch_messages NOTIFICATION_CACHE_KEY, current_path, user_access_level do
current_and_future_messages.notification
end
end
- def current(current_path = nil)
- fetch_messages CACHE_KEY, current_path do
+ def current(current_path: nil, user_access_level: nil)
+ fetch_messages CACHE_KEY, current_path, user_access_level do
current_and_future_messages
end
end
@@ -53,7 +62,7 @@ class BroadcastMessage < ApplicationRecord
def cache
::Gitlab::SafeRequestStore.fetch(:broadcast_message_json_cache) do
- Gitlab::JsonCache.new(cache_key_with_version: false)
+ Gitlab::JsonCache.new
end
end
@@ -63,7 +72,7 @@ class BroadcastMessage < ApplicationRecord
private
- def fetch_messages(cache_key, current_path)
+ def fetch_messages(cache_key, current_path, user_access_level)
messages = cache.fetch(cache_key, as: BroadcastMessage, expires_in: cache_expires_in) do
yield
end
@@ -74,7 +83,13 @@ class BroadcastMessage < ApplicationRecord
# displaying we'll refresh the cache so we don't need to keep filtering.
cache.expire(cache_key) if now_or_future != messages
- now_or_future.select(&:now?).select { |message| message.matches_current_path(current_path) }
+ messages = now_or_future.select(&:now?)
+ messages = messages.select do |message|
+ message.matches_current_user_access_level?(user_access_level)
+ end
+ messages.select do |message|
+ message.matches_current_path(current_path)
+ end
end
end
@@ -102,6 +117,13 @@ class BroadcastMessage < ApplicationRecord
now? || future?
end
+ def matches_current_user_access_level?(user_access_level)
+ return false if target_access_levels.present? && Feature.disabled?(:role_targeted_broadcast_messages, default_enabled: :yaml)
+ return true unless target_access_levels.present?
+
+ target_access_levels.include? user_access_level
+ end
+
def matches_current_path(current_path)
return false if current_path.blank? && target_path.present?
return true if current_path.blank? || target_path.blank?
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 38b7da76306..a7e1384641c 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -20,6 +20,8 @@
class BulkImports::Entity < ApplicationRecord
self.table_name = 'bulk_import_entities'
+ FailedError = Class.new(StandardError)
+
belongs_to :bulk_import, optional: false
belongs_to :parent, class_name: 'BulkImports::Entity', optional: true
diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb
index abf064adaae..cae6aad27da 100644
--- a/app/models/bulk_imports/export_status.rb
+++ b/app/models/bulk_imports/export_status.rb
@@ -30,7 +30,12 @@ module BulkImports
def export_status
strong_memoize(:export_status) do
- fetch_export_status.find { |item| item['relation'] == relation }
+ status = fetch_export_status
+
+ # Consider empty response as failed export
+ raise StandardError, 'Empty export status response' unless status&.present?
+
+ status.find { |item| item['relation'] == relation }
end
rescue StandardError => e
{ 'status' => Export::FAILED, 'error' => e.message }
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 50bda64d537..2ff777bfc89 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -11,6 +11,11 @@ module Ci
InvalidBridgeTypeError = Class.new(StandardError)
InvalidTransitionError = Class.new(StandardError)
+ FORWARD_DEFAULTS = {
+ yaml_variables: true,
+ pipeline_variables: false
+ }.freeze
+
belongs_to :project
belongs_to :trigger_request
has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline",
@@ -199,12 +204,13 @@ module Ci
end
def downstream_variables
- variables = scoped_variables.concat(pipeline.persisted_variables)
-
- variables.to_runner_variables.yield_self do |all_variables|
- yaml_variables.to_a.map do |hash|
- { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], all_variables) }
- end
+ if ::Feature.enabled?(:ci_trigger_forward_variables, project, default_enabled: :yaml)
+ calculate_downstream_variables
+ .reverse # variables priority
+ .uniq { |var| var[:key] } # only one variable key to pass
+ .reverse
+ else
+ legacy_downstream_variables
end
end
@@ -250,6 +256,58 @@ module Ci
}
}
end
+
+ def legacy_downstream_variables
+ variables = scoped_variables.concat(pipeline.persisted_variables)
+
+ variables.to_runner_variables.yield_self do |all_variables|
+ yaml_variables.to_a.map do |hash|
+ { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], all_variables) }
+ end
+ end
+ end
+
+ def calculate_downstream_variables
+ expand_variables = scoped_variables
+ .concat(pipeline.persisted_variables)
+ .to_runner_variables
+
+ # The order of this list refers to the priority of the variables
+ downstream_yaml_variables(expand_variables) +
+ downstream_pipeline_variables(expand_variables)
+ end
+
+ def downstream_yaml_variables(expand_variables)
+ return [] unless forward_yaml_variables?
+
+ yaml_variables.to_a.map do |hash|
+ { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], expand_variables) }
+ end
+ end
+
+ def downstream_pipeline_variables(expand_variables)
+ return [] unless forward_pipeline_variables?
+
+ pipeline.variables.to_a.map do |variable|
+ { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
+ end
+ end
+
+ def forward_yaml_variables?
+ strong_memoize(:forward_yaml_variables) do
+ result = options&.dig(:trigger, :forward, :yaml_variables)
+
+ result.nil? ? FORWARD_DEFAULTS[:yaml_variables] : result
+ end
+ end
+
+ def forward_pipeline_variables?
+ strong_memoize(:forward_pipeline_variables) do
+ result = options&.dig(:trigger, :forward, :pipeline_variables)
+
+ result.nil? ? FORWARD_DEFAULTS[:pipeline_variables] : result
+ end
+ end
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index c4d1a2c740b..68ec196a9ee 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -10,6 +10,8 @@ module Ci
include Presentable
include Importable
include Ci::HasRef
+ include HasDeploymentName
+
extend ::Gitlab::Utils::Override
BuildArchivedError = Class.new(StandardError)
@@ -35,6 +37,8 @@ module Ci
DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD'
RUNNERS_STATUS_CACHE_EXPIRATION = 1.minute
+ DEPLOYMENT_NAMES = %w[deploy release rollout].freeze
+
has_one :deployment, as: :deployable, class_name: 'Deployment'
has_one :pending_state, class_name: 'Ci::BuildPendingState', inverse_of: :build
has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id
@@ -68,6 +72,7 @@ module Ci
delegate :terminal_specification, to: :runner_session, allow_nil: true
delegate :service_specification, to: :runner_session, allow_nil: true
delegate :gitlab_deploy_token, to: :project
+ delegate :harbor_integration, to: :project
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
##
@@ -579,6 +584,7 @@ module Ci
.append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false, masked: true)
.append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false)
.concat(deploy_token_variables)
+ .concat(harbor_variables)
end
end
@@ -615,6 +621,12 @@ module Ci
end
end
+ def harbor_variables
+ return [] unless harbor_integration.try(:activated?)
+
+ Gitlab::Ci::Variables::Collection.new(harbor_integration.ci_variables)
+ end
+
def features
{
trace_sections: true,
@@ -1123,6 +1135,10 @@ module Ci
.include?(exit_code)
end
+ def track_deployment_usage
+ Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_deployment_job', user_id) if user_id.present? && count_user_deployment?
+ end
+
protected
def run_status_commit_hooks!
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index 165bee5c54d..0af5533613f 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -18,5 +18,6 @@ module Ci
scope :unprotected, -> { where(protected: false) }
scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) }
+ scope :for_groups, ->(group_ids) { where(group_id: group_ids) }
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index a1311b8555f..ae3ea7aa03f 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -25,6 +25,7 @@ module Ci
}.freeze
CONFIG_EXTENSION = '.gitlab-ci.yml'
DEFAULT_CONFIG_PATH = CONFIG_EXTENSION
+ CANCELABLE_STATUSES = (Ci::HasStatus::CANCELABLE_STATUSES + ['manual']).freeze
BridgeStatusError = Class.new(StandardError)
@@ -421,9 +422,7 @@ module Ci
sql = sql.where(ref: ref) if ref
- sql.each_with_object({}) do |pipeline, hash|
- hash[pipeline.sha] = pipeline
- end
+ sql.index_by(&:sha)
end
def self.latest_successful_ids_per_project
@@ -653,7 +652,7 @@ module Ci
def coverage
coverage_array = latest_statuses.map(&:coverage).compact
if coverage_array.size >= 1
- coverage_array.reduce(:+) / coverage_array.size
+ coverage_array.sum / coverage_array.size
end
end
@@ -1165,11 +1164,7 @@ module Ci
end
def merge_request?
- if Feature.enabled?(:ci_pipeline_merge_request_presence_check, default_enabled: :yaml)
- merge_request_id.present? && merge_request
- else
- merge_request_id.present?
- end
+ merge_request_id.present? && merge_request.present?
end
def external_pull_request?
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index b915495ac38..96e5567e85e 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -66,6 +66,18 @@ module Ci
project.actual_limits.limit_for(:ci_daily_pipeline_schedule_triggers)
end
+ def ref_for_display
+ return unless ref.present?
+
+ ref.gsub(%r{^refs/(heads|tags)/}, '')
+ end
+
+ def for_tag?
+ return false unless ref.present?
+
+ ref.start_with? 'refs/tags/'
+ end
+
private
def worker_cron_expression
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 372df8cc264..4d119706a43 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -16,7 +16,7 @@ module Ci
scope :with_needs, -> (names = nil) do
needs = Ci::BuildNeed.scoped_build.select(1)
needs = needs.where(name: names) if names
- where('EXISTS (?)', needs).preload(:needs)
+ where('EXISTS (?)', needs)
end
scope :without_needs, -> (names = nil) do
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 11150e839a3..4228da279a4 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -59,7 +59,7 @@ module Ci
AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze
AVAILABLE_TYPES = runner_types.keys.freeze
- AVAILABLE_STATUSES = %w[active paused online offline not_connected never_contacted stale].freeze # TODO: Remove in %15.0: active, paused, not_connected. Relevant issues: https://gitlab.com/gitlab-org/gitlab/-/issues/347303, https://gitlab.com/gitlab-org/gitlab/-/issues/347305, https://gitlab.com/gitlab-org/gitlab/-/issues/344648
+ AVAILABLE_STATUSES = %w[active paused online offline not_connected never_contacted stale].freeze # TODO: Remove in %15.0: not_connected. In %16.0: active, paused. Relevant issues: https://gitlab.com/gitlab-org/gitlab/-/issues/347303, https://gitlab.com/gitlab-org/gitlab/-/issues/347305, https://gitlab.com/gitlab-org/gitlab/-/issues/344648
AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze
FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
@@ -200,7 +200,7 @@ module Ci
validates :config, json_schema: { filename: 'ci_runner_config' }
- validates :maintenance_note, length: { maximum: 255 }
+ validates :maintenance_note, length: { maximum: 1024 }
alias_attribute :maintenance_note, :maintainer_note
@@ -329,9 +329,9 @@ module Ci
end
# DEPRECATED
- # TODO Remove in %15.0 in favor of `status` for REST calls, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648
+ # TODO Remove in %16.0 in favor of `status` for REST calls
def deprecated_rest_status
- if contacted_at.nil?
+ if contacted_at.nil? # TODO Remove in %15.0, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648
:not_connected
elsif active?
online? ? :online : :offline
diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb
index 56f632b6232..18f0093ea41 100644
--- a/app/models/ci/secure_file.rb
+++ b/app/models/ci/secure_file.rb
@@ -3,10 +3,14 @@
module Ci
class SecureFile < Ci::ApplicationRecord
include FileStoreMounter
+ include Limitable
FILE_SIZE_LIMIT = 5.megabytes.freeze
CHECKSUM_ALGORITHM = 'sha256'
+ self.limit_scope = :project
+ self.limit_name = 'project_ci_secure_files'
+
belongs_to :project, optional: false
validates :file, presence: true, file_size: { maximum: FILE_SIZE_LIMIT }
diff --git a/app/models/concerns/blocks_json_serialization.rb b/app/models/concerns/blocks_json_serialization.rb
deleted file mode 100644
index 18c00532d78..00000000000
--- a/app/models/concerns/blocks_json_serialization.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-# Overrides `as_json` and `to_json` to raise an exception when called in order
-# to prevent accidentally exposing attributes
-#
-# Not that would ever happen... but just in case.
-module BlocksJsonSerialization
- extend ActiveSupport::Concern
-
- JsonSerializationError = Class.new(StandardError)
-
- def to_json(*)
- raise JsonSerializationError,
- "JSON serialization has been disabled on #{self.class.name}"
- end
-
- alias_method :as_json, :to_json
-end
diff --git a/app/models/concerns/blocks_unsafe_serialization.rb b/app/models/concerns/blocks_unsafe_serialization.rb
new file mode 100644
index 00000000000..72adbe70f15
--- /dev/null
+++ b/app/models/concerns/blocks_unsafe_serialization.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+# Overrides `#serializable_hash` to raise an exception when called without the `only` option
+# in order to prevent accidentally exposing attributes.
+#
+# An `unsafe: true` option can also be passed in to bypass this check.
+#
+# `#serializable_hash` is used by ActiveModel serializers like `ActiveModel::Serializers::JSON`
+# which overrides `#as_json` and `#to_json`.
+#
+module BlocksUnsafeSerialization
+ extend ActiveSupport::Concern
+ extend ::Gitlab::Utils::Override
+
+ UnsafeSerializationError = Class.new(StandardError)
+
+ override :serializable_hash
+ def serializable_hash(options = nil)
+ return super if allow_serialization?(options)
+
+ raise UnsafeSerializationError,
+ "Serialization has been disabled on #{self.class.name}"
+ end
+
+ private
+
+ def allow_serialization?(options = nil)
+ return false unless options
+
+ !!(options[:only] || options[:unsafe])
+ end
+end
diff --git a/app/models/concerns/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb
index 927d6ccb28f..efc65e55e40 100644
--- a/app/models/concerns/bulk_member_access_load.rb
+++ b/app/models/concerns/bulk_member_access_load.rb
@@ -1,61 +1,19 @@
# frozen_string_literal: true
-# Returns and caches in thread max member access for a resource
-#
module BulkMemberAccessLoad
extend ActiveSupport::Concern
included do
- # Determine the maximum access level for a group of resources in bulk.
- #
- # Returns a Hash mapping resource ID -> maximum access level.
- def max_member_access_for_resource_ids(resource_klass, resource_ids, &block)
- raise 'Block is mandatory' unless block_given?
-
- memoization_index = self.id
- memoization_class = self.class
-
- resource_ids = resource_ids.uniq
- memo_id = "#{memoization_class}:#{memoization_index}"
- access = load_access_hash(resource_klass, memo_id)
-
- # Look up only the IDs we need
- resource_ids -= access.keys
-
- return access if resource_ids.empty?
-
- resource_access = yield(resource_ids)
-
- access.merge!(resource_access)
-
- missing_resource_ids = resource_ids - resource_access.keys
-
- missing_resource_ids.each do |resource_id|
- access[resource_id] = Gitlab::Access::NO_ACCESS
- end
-
- access
- end
-
def merge_value_to_request_store(resource_klass, resource_id, value)
- max_member_access_for_resource_ids(resource_klass, [resource_id]) do
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(resource_klass),
+ resource_ids: [resource_id],
+ default_value: Gitlab::Access::NO_ACCESS) do
{ resource_id => value }
end
end
- private
-
- def max_member_access_for_resource_key(klass, memoization_index)
- "max_member_access_for_#{klass.name.underscore.pluralize}:#{memoization_index}"
- end
-
- def load_access_hash(resource_klass, memo_id)
- return {} unless Gitlab::SafeRequestStore.active?
-
- key = max_member_access_for_resource_key(resource_klass, memo_id)
- Gitlab::SafeRequestStore[key] ||= {}
-
- Gitlab::SafeRequestStore[key]
+ def max_member_access_for_resource_key(klass)
+ "max_member_access_for_#{klass.name.underscore.pluralize}:#{self.class}:#{self.id}"
end
end
end
diff --git a/app/models/concerns/ci/has_deployment_name.rb b/app/models/concerns/ci/has_deployment_name.rb
new file mode 100644
index 00000000000..fe288134872
--- /dev/null
+++ b/app/models/concerns/ci/has_deployment_name.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Ci
+ module HasDeploymentName
+ extend ActiveSupport::Concern
+
+ def count_user_deployment?
+ Feature.enabled?(:job_deployment_count) && deployment_name?
+ end
+
+ def deployment_name?
+ self.class::DEPLOYMENT_NAMES.any? { |n| name.downcase.include?(n) }
+ end
+ end
+end
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb
index ccaccec3b6b..313c767e59f 100644
--- a/app/models/concerns/ci/has_status.rb
+++ b/app/models/concerns/ci/has_status.rb
@@ -7,12 +7,16 @@ module Ci
DEFAULT_STATUS = 'created'
BLOCKED_STATUS = %w[manual scheduled].freeze
AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze
+ # TODO: replace STARTED_STATUSES with data from BUILD_STARTED_RUNNING_STATUSES in https://gitlab.com/gitlab-org/gitlab/-/issues/273378
+ # see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82149#note_865508501
+ BUILD_STARTED_RUNNING_STATUSES = %w[running success failed].freeze
STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze
ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze
COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze
PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze
EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze
+ CANCELABLE_STATUSES = %w[running waiting_for_resource preparing pending created scheduled].freeze
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
failed: 4, canceled: 5, skipped: 6, manual: 7,
scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze
@@ -85,7 +89,7 @@ module Ci
scope :waiting_for_resource_or_upcoming, -> { with_status(:created, :scheduled, :waiting_for_resource) }
scope :cancelable, -> do
- where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled])
+ where(status: klass::CANCELABLE_STATUSES)
end
scope :without_statuses, -> (names) do
diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb
index 4bfeba338d2..b41b1ba6008 100644
--- a/app/models/concerns/counter_attribute.rb
+++ b/app/models/concerns/counter_attribute.rb
@@ -102,9 +102,7 @@ module CounterAttribute
run_after_commit_or_now do
if counter_attribute_enabled?(attribute)
- redis_state do |redis|
- redis.incrby(counter_key(attribute), increment)
- end
+ increment_counter(attribute, increment)
FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute)
else
@@ -115,6 +113,28 @@ module CounterAttribute
true
end
+ def increment_counter(attribute, increment)
+ if counter_attribute_enabled?(attribute)
+ redis_state do |redis|
+ redis.incrby(counter_key(attribute), increment)
+ end
+ end
+ end
+
+ def clear_counter!(attribute)
+ if counter_attribute_enabled?(attribute)
+ redis_state { |redis| redis.del(counter_key(attribute)) }
+ end
+ end
+
+ def get_counter_value(attribute)
+ if counter_attribute_enabled?(attribute)
+ redis_state do |redis|
+ redis.get(counter_key(attribute)).to_i
+ end
+ end
+ end
+
def counter_key(attribute)
"project:{#{project_id}}:counters:#{self.class}:#{id}:#{attribute}"
end
diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
index b6245e29746..d9c622f247a 100644
--- a/app/models/concerns/deployment_platform.rb
+++ b/app/models/concerns/deployment_platform.rb
@@ -3,6 +3,8 @@
module DeploymentPlatform
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def deployment_platform(environment: nil)
+ return if Feature.disabled?(:certificate_based_clusters, default_enabled: :yaml, type: :ops)
+
@deployment_platform ||= {}
@deployment_platform[environment] ||= find_deployment_platform(environment)
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
index 28ee54afaa9..ad070090dd5 100644
--- a/app/models/concerns/has_user_type.rb
+++ b/app/models/concerns/has_user_type.rb
@@ -46,4 +46,17 @@ module HasUserType
def internal?
ghost? || (bot? && !project_bot?)
end
+
+ def redacted_name(viewing_user)
+ return self.name unless self.project_bot?
+
+ return self.name if self.groups.any? && viewing_user&.can?(:read_group, self.groups.first)
+
+ return self.name if viewing_user&.can?(:read_project, self.projects.first)
+
+ # If the requester does not have permission to read the project bot name,
+ # the API returns an arbitrary string. UI changes will be addressed in a follow up issue:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/346058
+ '****'
+ end
end
diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb
new file mode 100644
index 00000000000..b1def38d019
--- /dev/null
+++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Integrations
+ module HasIssueTrackerFields
+ extend ActiveSupport::Concern
+
+ included do
+ field :project_url,
+ required: true,
+ storage: :data_fields,
+ title: -> { _('Project URL') },
+ help: -> { s_('IssueTracker|The URL to the project in the external issue tracker.') }
+
+ field :issues_url,
+ required: true,
+ storage: :data_fields,
+ title: -> { s_('IssueTracker|Issue URL') },
+ help: -> do
+ format s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.'),
+ colon_id: '<code>:id</code>'.html_safe
+ end
+
+ field :new_issue_url,
+ required: true,
+ storage: :data_fields,
+ title: -> { s_('IssueTracker|New issue URL') },
+ help: -> { s_('IssueTracker|The URL to create an issue in the external issue tracker.') }
+ end
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 0138c0ad20f..1eb30e88f16 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -74,6 +74,7 @@ module Issuable
end
has_many :note_authors, -> { distinct }, through: :notes, source: :author
+ has_many :user_note_authors, -> { distinct.where("notes.system = false") }, through: :notes, source: :author
has_many :label_links, as: :target, inverse_of: :target
has_many :labels, through: :label_links
@@ -464,37 +465,54 @@ module Issuable
false
end
- def to_hook_data(user, old_associations: {})
- changes = previous_changes
+ def hook_association_changes(old_associations)
+ changes = {}
- if old_associations
- old_labels = old_associations.fetch(:labels, labels)
- old_assignees = old_associations.fetch(:assignees, assignees)
- old_severity = old_associations.fetch(:severity, severity)
+ old_labels = old_associations.fetch(:labels, labels)
+ old_assignees = old_associations.fetch(:assignees, assignees)
+ old_severity = old_associations.fetch(:severity, severity)
- if old_labels != labels
- changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
- end
+ if old_labels != labels
+ changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
+ end
- if old_assignees != assignees
- changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
- end
+ if old_assignees != assignees
+ changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
+ end
+
+ if supports_severity? && old_severity != severity
+ changes[:severity] = [old_severity, severity]
+ end
+
+ if supports_escalation? && escalation_status
+ current_escalation_status = escalation_status.status_name
+ old_escalation_status = old_associations.fetch(:escalation_status, current_escalation_status)
- if supports_severity? && old_severity != severity
- changes[:severity] = [old_severity, severity]
+ if old_escalation_status != current_escalation_status
+ changes[:escalation_status] = [old_escalation_status, current_escalation_status]
end
+ end
- if self.respond_to?(:total_time_spent)
- old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent)
- old_time_change = old_associations.fetch(:time_change, time_change)
+ if self.respond_to?(:total_time_spent)
+ old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent)
+ old_time_change = old_associations.fetch(:time_change, time_change)
- if old_total_time_spent != total_time_spent
- changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
- changes[:time_change] = [old_time_change, time_change]
- end
+ if old_total_time_spent != total_time_spent
+ changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ changes[:time_change] = [old_time_change, time_change]
end
end
+ changes
+ end
+
+ def to_hook_data(user, old_associations: {})
+ changes = previous_changes
+
+ if old_associations.present?
+ changes.merge!(hook_association_changes(old_associations))
+ end
+
Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes)
end
diff --git a/app/models/concerns/issuable_link.rb b/app/models/concerns/issuable_link.rb
new file mode 100644
index 00000000000..3e14507bc70
--- /dev/null
+++ b/app/models/concerns/issuable_link.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+# == IssuableLink concern
+#
+# Contains common functionality shared between related Issues and related Epics
+#
+# Used by IssueLink, Epic::RelatedEpicLink
+#
+module IssuableLink
+ extend ActiveSupport::Concern
+
+ TYPE_RELATES_TO = 'relates_to'
+ TYPE_BLOCKS = 'blocks' ## EE-only. Kept here to be used on link_type enum.
+
+ class_methods do
+ def inverse_link_type(type)
+ type
+ end
+
+ def issuable_type
+ raise NotImplementedError
+ end
+ end
+
+ included do
+ validates :source, presence: true
+ validates :target, presence: true
+ validates :source, uniqueness: { scope: :target_id, message: 'is already related' }
+ validate :check_self_relation
+ validate :check_opposite_relation
+
+ enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 }
+
+ private
+
+ def check_self_relation
+ return unless source && target
+
+ if source == target
+ errors.add(:source, 'cannot be related to itself')
+ end
+ end
+
+ def check_opposite_relation
+ return unless source && target
+
+ if self.class.base_class.find_by(source: target, target: source)
+ errors.add(:source, "is already related to this #{self.class.issuable_type}")
+ end
+ end
+ end
+end
+
+IssuableLink.prepend_mod_with('IssuableLink')
+IssuableLink::ClassMethods.prepend_mod_with('IssuableLink::ClassMethods')
diff --git a/app/models/concerns/issue_resource_event.rb b/app/models/concerns/issue_resource_event.rb
index 1c24032dbbb..5cbc937e465 100644
--- a/app/models/concerns/issue_resource_event.rb
+++ b/app/models/concerns/issue_resource_event.rb
@@ -8,6 +8,10 @@ module IssueResourceEvent
scope :by_issue, ->(issue) { where(issue_id: issue.id) }
- scope :by_issue_ids_and_created_at_earlier_or_equal_to, ->(issue_ids, time) { where(issue_id: issue_ids).where('created_at <= ?', time) }
+ scope :by_created_at_earlier_or_equal_to, ->(time) { where('created_at <= ?', time) }
+ scope :by_issue_ids, ->(issue_ids) do
+ table = self.klass.arel_table
+ where(table[:issue_id].in(issue_ids))
+ end
end
end
diff --git a/app/models/concerns/merge_request_reviewer_state.rb b/app/models/concerns/merge_request_reviewer_state.rb
index 5859f43a70c..893d06b4da8 100644
--- a/app/models/concerns/merge_request_reviewer_state.rb
+++ b/app/models/concerns/merge_request_reviewer_state.rb
@@ -14,6 +14,14 @@ module MergeRequestReviewerState
presence: true,
inclusion: { in: self.states.keys }
+ belongs_to :updated_state_by, class_name: 'User', foreign_key: :updated_state_by_user_id
+
after_initialize :set_state, unless: :persisted?
+
+ def attention_requested_by
+ return unless attention_requested?
+
+ updated_state_by
+ end
end
end
diff --git a/app/models/concerns/pg_full_text_searchable.rb b/app/models/concerns/pg_full_text_searchable.rb
new file mode 100644
index 00000000000..68357c44300
--- /dev/null
+++ b/app/models/concerns/pg_full_text_searchable.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+# This module adds PG full-text search capabilities to a model.
+# A `search_data` association with a `search_vector` column is required.
+#
+# Declare the fields that will be part of the search vector with their
+# corresponding weights. Possible values for weight are A, B, C, or D.
+# For example:
+#
+# include PgFullTextSearchable
+# pg_full_text_searchable columns: [{ name: 'title', weight: 'A' }, { name: 'description', weight: 'B' }]
+#
+# This module sets up an after_commit hook that updates the search data
+# when the searchable columns are changed. You will need to implement the
+# `#persist_pg_full_text_search_vector` method that does the actual insert or update.
+#
+# This also adds a `pg_full_text_search` scope so you can do:
+#
+# Model.pg_full_text_search("some search term")
+
+module PgFullTextSearchable
+ extend ActiveSupport::Concern
+
+ LONG_WORDS_REGEX = %r([A-Za-z0-9+/@]{50,}).freeze
+ TSVECTOR_MAX_LENGTH = 1.megabyte.freeze
+ TEXT_SEARCH_DICTIONARY = 'english'
+
+ def update_search_data!
+ tsvector_sql_nodes = self.class.pg_full_text_searchable_columns.map do |column, weight|
+ tsvector_arel_node(column, weight)&.to_sql
+ end
+
+ persist_pg_full_text_search_vector(Arel.sql(tsvector_sql_nodes.compact.join(' || ')))
+ rescue ActiveRecord::StatementInvalid => e
+ raise unless e.cause.is_a?(PG::ProgramLimitExceeded) && e.message.include?('string is too long for tsvector')
+
+ Gitlab::AppJsonLogger.error(
+ message: 'Error updating search data: string is too long for tsvector',
+ class: self.class.name,
+ model_id: self.id
+ )
+ end
+
+ private
+
+ def persist_pg_full_text_search_vector(search_vector)
+ raise NotImplementedError
+ end
+
+ def tsvector_arel_node(column, weight)
+ return if self[column].blank?
+
+ column_text = self[column].gsub(LONG_WORDS_REGEX, ' ')
+ column_text = column_text[0..(TSVECTOR_MAX_LENGTH - 1)]
+ column_text = ActiveSupport::Inflector.transliterate(column_text)
+
+ Arel::Nodes::NamedFunction.new(
+ 'setweight',
+ [
+ Arel::Nodes::NamedFunction.new(
+ 'to_tsvector',
+ [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(column_text)]
+ ),
+ Arel::Nodes.build_quoted(weight)
+ ]
+ )
+ end
+
+ included do
+ cattr_reader :pg_full_text_searchable_columns do
+ {}
+ end
+ end
+
+ class_methods do
+ def pg_full_text_searchable(columns:)
+ raise 'Full text search columns already defined!' if pg_full_text_searchable_columns.present?
+
+ columns.each do |column|
+ pg_full_text_searchable_columns[column[:name]] = column[:weight]
+ end
+
+ # We update this outside the transaction because this could raise an error if the resulting tsvector
+ # is too long. When that happens, we still persist the create / update but the model will not have a
+ # search data record. This is fine in most cases because this is a very rare occurrence and only happens
+ # with strings that are most likely unsearchable anyway.
+ #
+ # We also do not want to use a subtransaction here due to: https://gitlab.com/groups/gitlab-org/-/epics/6540
+ after_save_commit do
+ next unless pg_full_text_searchable_columns.keys.any? { |f| saved_changes.has_key?(f) }
+
+ update_search_data!
+ end
+ end
+
+ def pg_full_text_search(search_term)
+ search_data_table = reflect_on_association(:search_data).klass.arel_table
+
+ joins(:search_data).where(
+ Arel::Nodes::InfixOperation.new(
+ '@@',
+ search_data_table[:search_vector],
+ Arel::Nodes::NamedFunction.new(
+ 'websearch_to_tsquery',
+ [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(search_term)]
+ )
+ )
+ )
+ end
+ end
+end
diff --git a/app/models/concerns/runners_token_prefixable.rb b/app/models/concerns/runners_token_prefixable.rb
index 1aea874337e..99bbbece7c7 100644
--- a/app/models/concerns/runners_token_prefixable.rb
+++ b/app/models/concerns/runners_token_prefixable.rb
@@ -1,14 +1,8 @@
# frozen_string_literal: true
module RunnersTokenPrefixable
- extend ActiveSupport::Concern
-
# Prefix for runners_token which can be used to invalidate existing tokens.
# The value chosen here is GR (for Gitlab Runner) combined with the rotation
# date (20220225) decimal to hex encoded.
RUNNERS_TOKEN_PREFIX = 'GR1348941'
-
- def runners_token_prefix
- RUNNERS_TOKEN_PREFIX
- end
end
diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb
index 49342e30db6..5a7e16eb2c4 100644
--- a/app/models/concerns/select_for_project_authorization.rb
+++ b/app/models/concerns/select_for_project_authorization.rb
@@ -8,8 +8,10 @@ module SelectForProjectAuthorization
select("projects.id AS project_id", "members.access_level")
end
- def select_as_maintainer_for_project_authorization
- select(["projects.id AS project_id", "#{Gitlab::Access::MAINTAINER} AS access_level"])
+ # workaround until we migrate Project#owners to have membership with
+ # OWNER access level
+ def select_project_owner_for_project_authorization
+ select(["projects.id AS project_id", "#{Gitlab::Access::OWNER} AS access_level"])
end
end
end
diff --git a/app/models/concerns/sensitive_serializable_hash.rb b/app/models/concerns/sensitive_serializable_hash.rb
new file mode 100644
index 00000000000..725ec60e9b6
--- /dev/null
+++ b/app/models/concerns/sensitive_serializable_hash.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module SensitiveSerializableHash
+ extend ActiveSupport::Concern
+
+ included do
+ class_attribute :attributes_exempt_from_serializable_hash, default: []
+ end
+
+ class_methods do
+ def prevent_from_serialization(*keys)
+ self.attributes_exempt_from_serializable_hash ||= []
+ self.attributes_exempt_from_serializable_hash.concat keys
+ end
+ end
+
+ # Override serializable_hash to exclude sensitive attributes by default
+ #
+ # In general, prefer NOT to use serializable_hash / to_json / as_json in favor
+ # of serializers / entities instead which has an allowlist of attributes
+ def serializable_hash(options = nil)
+ return super unless prevent_sensitive_fields_from_serializable_hash?
+ return super if options && options[:unsafe_serialization_hash]
+
+ options = options.try(:dup) || {}
+ options[:except] = Array(options[:except]).dup
+
+ options[:except].concat self.class.attributes_exempt_from_serializable_hash
+
+ if self.class.respond_to?(:encrypted_attributes)
+ options[:except].concat self.class.encrypted_attributes.keys
+
+ # Per https://github.com/attr-encrypted/attr_encrypted/blob/a96693e9a2a25f4f910bf915e29b0f364f277032/lib/attr_encrypted.rb#L413
+ options[:except].concat self.class.encrypted_attributes.values.map { |v| v[:attribute] }
+ options[:except].concat self.class.encrypted_attributes.values.map { |v| "#{v[:attribute]}_iv" }
+ end
+
+ super(options)
+ end
+
+ private
+
+ def prevent_sensitive_fields_from_serializable_hash?
+ Feature.enabled?(:prevent_sensitive_fields_from_serializable_hash, default_enabled: :yaml)
+ end
+end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 4901cd832ff..b475eb79aa3 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -12,7 +12,7 @@ module Spammable
included do
has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- attr_accessor :spam
+ attr_writer :spam
attr_accessor :needs_recaptcha
attr_accessor :spam_log
@@ -29,6 +29,10 @@ module Spammable
delegate :ip_address, :user_agent, to: :user_agent_detail, allow_nil: true
end
+ def spam
+ !!@spam # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
def submittable_as_spam_by?(current_user)
current_user && current_user.admin? && submittable_as_spam?
end
@@ -74,8 +78,9 @@ module Spammable
end
def recaptcha_error!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\
- "Please, change the content or solve the reCAPTCHA to proceed.")
+ self.errors.add(:base, _("Your %{spammable_entity_type} has been recognized as spam. "\
+ "Please, change the content or solve the reCAPTCHA to proceed.") \
+ % { spammable_entity_type: spammable_entity_type })
end
def unrecoverable_spam_error!
diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb
index 943ef3fa59f..d53594eb5af 100644
--- a/app/models/concerns/timebox.rb
+++ b/app/models/concerns/timebox.rb
@@ -44,7 +44,6 @@ module Timebox
validates :group, presence: true, unless: :project
validates :project, presence: true, unless: :group
- validates :title, presence: true
validate :timebox_type_check
validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb
index f44ad8ebe90..d91ec161b84 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -8,6 +8,10 @@ module TokenAuthenticatable
@encrypted_token_authenticatable_fields ||= []
end
+ def token_authenticatable_fields
+ @token_authenticatable_fields ||= []
+ end
+
private
def add_authentication_token_field(token_field, options = {})
@@ -23,6 +27,8 @@ module TokenAuthenticatable
strategy = TokenAuthenticatableStrategies::Base
.fabricate(self, token_field, options)
+ prevent_from_serialization(*strategy.token_fields) if respond_to?(:prevent_from_serialization)
+
if options.fetch(:unique, true)
define_singleton_method("find_by_#{token_field}") do |token|
strategy.find_token_authenticatable(token)
@@ -82,9 +88,5 @@ module TokenAuthenticatable
@token_authenticatable_module ||=
const_set(:TokenAuthenticatable, Module.new).tap(&method(:include))
end
-
- def token_authenticatable_fields
- @token_authenticatable_fields ||= []
- end
end
end
diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb
index 2cec4ab460e..2b677f37c89 100644
--- a/app/models/concerns/token_authenticatable_strategies/base.rb
+++ b/app/models/concerns/token_authenticatable_strategies/base.rb
@@ -23,6 +23,14 @@ module TokenAuthenticatableStrategies
raise NotImplementedError
end
+ def token_fields
+ result = [token_field]
+
+ result << @expires_at_field if expirable?
+
+ result
+ end
+
# Default implementation returns the token as-is
def format_token(instance, token)
instance.send("format_#{@token_field}", token) # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/models/concerns/token_authenticatable_strategies/digest.rb b/app/models/concerns/token_authenticatable_strategies/digest.rb
index 9926662ed66..5c94f25949f 100644
--- a/app/models/concerns/token_authenticatable_strategies/digest.rb
+++ b/app/models/concerns/token_authenticatable_strategies/digest.rb
@@ -2,6 +2,10 @@
module TokenAuthenticatableStrategies
class Digest < Base
+ def token_fields
+ super + [token_field_name]
+ end
+
def find_token_authenticatable(token, unscoped = false)
return unless token
diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
index e957d09fbc6..1db88c27181 100644
--- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb
+++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
@@ -2,6 +2,10 @@
module TokenAuthenticatableStrategies
class Encrypted < Base
+ def token_fields
+ super + [encrypted_field]
+ end
+
def find_token_authenticatable(token, unscoped = false)
return if token.blank?
diff --git a/app/models/concerns/update_namespace_statistics.rb b/app/models/concerns/update_namespace_statistics.rb
new file mode 100644
index 00000000000..26d6fc10228
--- /dev/null
+++ b/app/models/concerns/update_namespace_statistics.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+# This module provides helpers for updating `NamespaceStatistics` with `after_save` and
+# `after_destroy` hooks.
+#
+# Models including this module must respond to and return a `namespace`
+#
+# Example:
+#
+# class DependencyProxy::Manifest
+# include UpdateNamespaceStatistics
+#
+# belongs_to :group
+# alias_attribute :namespace, :group
+#
+# update_namespace_statistics namespace_statistics_name: :dependency_proxy_size
+# end
+module UpdateNamespaceStatistics
+ extend ActiveSupport::Concern
+ include AfterCommitQueue
+
+ class_methods do
+ attr_reader :namespace_statistics_name, :statistic_attribute
+
+ # Configure the model to update `namespace_statistics_name` on NamespaceStatistics,
+ # when `statistic_attribute` changes
+ #
+ # - namespace_statistics_name: A column of `NamespaceStatistics` to update
+ # - statistic_attribute: An attribute of the current model, default to `size`
+ def update_namespace_statistics(namespace_statistics_name:, statistic_attribute: :size)
+ @namespace_statistics_name = namespace_statistics_name
+ @statistic_attribute = statistic_attribute
+
+ after_save(:schedule_namespace_statistics_refresh, if: :update_namespace_statistics?)
+ after_destroy(:schedule_namespace_statistics_refresh)
+ end
+
+ private :update_namespace_statistics
+ end
+
+ included do
+ private
+
+ def update_namespace_statistics?
+ saved_change_to_attribute?(self.class.statistic_attribute)
+ end
+
+ def schedule_namespace_statistics_refresh
+ run_after_commit do
+ Groups::UpdateStatisticsWorker.perform_async(namespace.id, [self.class.namespace_statistics_name])
+ end
+ end
+ end
+end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 1f123cb0244..fa03d73646d 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -14,6 +14,8 @@ class ContainerRepository < ApplicationRecord
ABORTABLE_MIGRATION_STATES = (ACTIVE_MIGRATION_STATES + %w[pre_import_done default]).freeze
MIGRATION_STATES = (IDLE_MIGRATION_STATES + ACTIVE_MIGRATION_STATES).freeze
+ MIGRATION_PHASE_1_STARTED_AT = Date.new(2021, 11, 4).freeze
+
TooManyImportsError = Class.new(StandardError)
NativeImportError = Class.new(StandardError)
@@ -64,7 +66,7 @@ class ContainerRepository < ApplicationRecord
# feature flag since it is only accessed in this query.
# https://gitlab.com/gitlab-org/gitlab/-/issues/350543 tracks the rollout and
# removal of this feature flag.
- joins(:project).where(
+ joins(project: [:namespace]).where(
migration_state: [:default],
created_at: ...ContainerRegistry::Migration.created_before
).with_target_import_tier
@@ -74,7 +76,7 @@ class ContainerRepository < ApplicationRecord
FROM feature_gates
WHERE feature_gates.feature_key = 'container_registry_phase_2_deny_list'
AND feature_gates.key = 'actors'
- AND feature_gates.value = concat('Group:', projects.namespace_id)
+ AND feature_gates.value = concat('Group:', namespaces.traversal_ids[1])
)"
)
end
@@ -408,6 +410,16 @@ class ContainerRepository < ApplicationRecord
update!(expiration_policy_started_at: Time.zone.now)
end
+ def size
+ strong_memoize(:size) do
+ next unless Gitlab.com?
+ next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT)
+ next unless gitlab_api_client.supports_gitlab_api?
+
+ gitlab_api_client.repository_details(self.path, with_size: true)['size_bytes']
+ end
+ end
+
def migration_in_active_state?
migration_state.in?(ACTIVE_MIGRATION_STATES)
end
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index a981351f4a0..4fa2c3fb8cf 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -23,8 +23,9 @@ class CustomerRelations::Contact < ApplicationRecord
validates :last_name, presence: true, length: { maximum: 255 }
validates :email, length: { maximum: 255 }
validates :description, length: { maximum: 1024 }
+ validates :email, uniqueness: { scope: :group_id }
validate :validate_email_format
- validate :unique_email_for_group_hierarchy
+ validate :validate_root_group
def self.reference_prefix
'[contact:'
@@ -41,14 +42,13 @@ class CustomerRelations::Contact < ApplicationRecord
def self.find_ids_by_emails(group, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
- where(group_id: group.self_and_ancestor_ids, email: emails)
- .pluck(:id)
+ where(group: group, email: emails).pluck(:id)
end
def self.exists_for_group?(group)
return false unless group
- exists?(group_id: group.self_and_ancestor_ids)
+ exists?(group: group)
end
private
@@ -59,13 +59,9 @@ class CustomerRelations::Contact < ApplicationRecord
self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email)
end
- def unique_email_for_group_hierarchy
- return unless group
- return unless email
+ def validate_root_group
+ return if group&.root?
- duplicate_email_exists = CustomerRelations::Contact
- .where(group_id: group.self_and_hierarchy.pluck(:id), email: email)
- .where.not(id: id).exists?
- self.errors.add(:email, _('contact with same email already exists in group hierarchy')) if duplicate_email_exists
+ self.errors.add(:base, _('contacts can only be added to root groups'))
end
end
diff --git a/app/models/customer_relations/issue_contact.rb b/app/models/customer_relations/issue_contact.rb
index 3e9d1e97c8c..dc7a3fd87bc 100644
--- a/app/models/customer_relations/issue_contact.rb
+++ b/app/models/customer_relations/issue_contact.rb
@@ -6,7 +6,7 @@ class CustomerRelations::IssueContact < ApplicationRecord
belongs_to :issue, optional: false, inverse_of: :customer_relations_contacts
belongs_to :contact, optional: false, inverse_of: :issue_contacts
- validate :contact_belongs_to_issue_group_or_ancestor
+ validate :contact_belongs_to_root_group
def self.find_contact_ids_by_emails(issue_id, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
@@ -24,11 +24,11 @@ class CustomerRelations::IssueContact < ApplicationRecord
private
- def contact_belongs_to_issue_group_or_ancestor
+ def contact_belongs_to_root_group
return unless contact&.group_id
return unless issue&.project&.namespace_id
- return if issue.project.group&.self_and_ancestor_ids&.include?(contact.group_id)
+ return if issue.project.root_ancestor&.id == contact.group_id
- errors.add(:base, _('The contact does not belong to the issue group or an ancestor'))
+ errors.add(:base, _("The contact does not belong to the issue group's root ancestor"))
end
end
diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb
index c206d1e05f5..a23b9d8fe28 100644
--- a/app/models/customer_relations/organization.rb
+++ b/app/models/customer_relations/organization.rb
@@ -19,9 +19,18 @@ class CustomerRelations::Organization < ApplicationRecord
validates :name, uniqueness: { case_sensitive: false, scope: [:group_id] }
validates :name, length: { maximum: 255 }
validates :description, length: { maximum: 1024 }
+ validate :validate_root_group
def self.find_by_name(group_id, name)
where(group: group_id)
.where('LOWER(name) = LOWER(?)', name)
end
+
+ private
+
+ def validate_root_group
+ return if group&.root?
+
+ self.errors.add(:base, _('organizations can only be added to root groups'))
+ end
end
diff --git a/app/models/dependency_proxy/blob.rb b/app/models/dependency_proxy/blob.rb
index f7b08f1d077..dc40ff62adb 100644
--- a/app/models/dependency_proxy/blob.rb
+++ b/app/models/dependency_proxy/blob.rb
@@ -5,8 +5,10 @@ class DependencyProxy::Blob < ApplicationRecord
include TtlExpirable
include Packages::Destructible
include EachBatch
+ include UpdateNamespaceStatistics
belongs_to :group
+ alias_attribute :namespace, :group
MAX_FILE_SIZE = 5.gigabytes.freeze
@@ -17,6 +19,7 @@ class DependencyProxy::Blob < ApplicationRecord
scope :with_files_stored_locally, -> { where(file_store: ::DependencyProxy::FileUploader::Store::LOCAL) }
mount_file_store_uploader DependencyProxy::FileUploader
+ update_namespace_statistics namespace_statistics_name: :dependency_proxy_size
def self.total_size
sum(:size)
diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb
index c2587ffac9d..5ad746e4cd1 100644
--- a/app/models/dependency_proxy/manifest.rb
+++ b/app/models/dependency_proxy/manifest.rb
@@ -5,8 +5,10 @@ class DependencyProxy::Manifest < ApplicationRecord
include TtlExpirable
include Packages::Destructible
include EachBatch
+ include UpdateNamespaceStatistics
belongs_to :group
+ alias_attribute :namespace, :group
MAX_FILE_SIZE = 10.megabytes.freeze
DIGEST_HEADER = 'Docker-Content-Digest'
@@ -20,6 +22,7 @@ class DependencyProxy::Manifest < ApplicationRecord
scope :with_files_stored_locally, -> { where(file_store: ::DependencyProxy::FileUploader::Store::LOCAL) }
mount_file_store_uploader DependencyProxy::FileUploader
+ update_namespace_statistics namespace_statistics_name: :dependency_proxy_size
def self.find_by_file_name_or_digest(file_name:, digest:)
find_by(file_name: file_name) || find_by(digest: digest)
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 46409465209..c06c809538a 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -8,7 +8,6 @@ class Deployment < ApplicationRecord
include Importable
include Gitlab::Utils::StrongMemoize
include FastDestroyAll
- include FromUnion
StatusUpdateError = Class.new(StandardError)
StatusSyncError = Class.new(StandardError)
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 6ebac6384bc..02979d5f804 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -145,7 +145,7 @@ class DiffNote < Note
end
def fetch_diff_file
- return note_diff_file.raw_diff_file if note_diff_file
+ return note_diff_file.raw_diff_file if note_diff_file && !note_diff_file.raw_diff_file.has_renderable?
if created_at_diff?(noteable.diff_refs)
# We're able to use the already persisted diffs (Postgres) if we're
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 51a9024721b..450ed6206d5 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -461,11 +461,16 @@ class Environment < ApplicationRecord
# See https://en.wikipedia.org/wiki/Deployment_environment for industry standard deployment environments
def guess_tier
case name
- when %r{dev|review|trunk}i then self.class.tiers[:development]
- when %r{test|qc}i then self.class.tiers[:testing]
- when %r{st(a|)g|mod(e|)l|pre|demo}i then self.class.tiers[:staging]
- when %r{pr(o|)d|live}i then self.class.tiers[:production]
- else self.class.tiers[:other]
+ when /(dev|review|trunk)/i
+ self.class.tiers[:development]
+ when /(test|tst|int|ac(ce|)pt|qa|qc|control|quality)/i
+ self.class.tiers[:testing]
+ when /(st(a|)g|mod(e|)l|pre|demo)/i
+ self.class.tiers[:staging]
+ when /(pr(o|)d|live)/i
+ self.class.tiers[:production]
+ else
+ self.class.tiers[:other]
end
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 25f812645b1..0a429bb7afd 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -59,6 +59,10 @@ module ErrorTracking
integrated
end
+ def integrated_enabled?
+ enabled? && integrated_client?
+ end
+
def gitlab_dsn
strong_memoize(:gitlab_dsn) do
client_key&.sentry_dsn
diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb
index f799377a15f..fc093894847 100644
--- a/app/models/event_collection.rb
+++ b/app/models/event_collection.rb
@@ -44,31 +44,31 @@ class EventCollection
private
def project_events
- relation_with_join_lateral('project_id', projects)
+ in_operator_optimized_relation('project_id', projects)
end
- def project_and_group_events
- group_events = relation_with_join_lateral('group_id', groups)
+ def group_events
+ in_operator_optimized_relation('group_id', groups)
+ end
+ def project_and_group_events
Event.from_union([project_events, group_events]).recent
end
- # This relation is built using JOIN LATERAL, producing faster queries than a
- # regular LIMIT + OFFSET approach.
- def relation_with_join_lateral(parent_column, parents)
- parents_for_lateral = parents.select(:id).to_sql
-
- lateral = filtered_events
- # Applying the limit here (before we filter (permissions) means we may get less than limit)
- .limit(limit_for_join_lateral)
- .where("events.#{parent_column} = parents_for_lateral.id") # rubocop:disable GitlabSecurity/SqlInjection
- .to_sql
-
- # The outer query does not need to re-apply the filters since the JOIN
- # LATERAL body already takes care of this.
- base_relation
- .from("(#{parents_for_lateral}) parents_for_lateral")
- .joins("JOIN LATERAL (#{lateral}) AS #{Event.table_name} ON true")
+ def in_operator_optimized_relation(parent_column, parents)
+ scope = filtered_events
+ array_scope = parents.select(:id)
+ array_mapping_scope = -> (parent_id_expression) { Event.where(Event.arel_table[parent_column].eq(parent_id_expression)).reorder(id: :desc) }
+ finder_query = -> (id_expression) { Event.where(Event.arel_table[:id].eq(id_expression)) }
+
+ Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
+ .new(
+ scope: scope,
+ array_scope: array_scope,
+ array_mapping_scope: array_mapping_scope,
+ finder_query: finder_query
+ )
+ .execute
end
def filtered_events
@@ -85,16 +85,6 @@ class EventCollection
Event.unscoped.recent
end
- def limit_for_join_lateral
- # Applying the OFFSET on the inside of a JOIN LATERAL leads to incorrect
- # results. To work around this we need to increase the inner limit for every
- # page.
- #
- # This means that on page 1 we use LIMIT 20, and an outer OFFSET of 0. On
- # page 2 we use LIMIT 40 and an outer OFFSET of 20.
- @limit + @offset
- end
-
def current_page
(@offset / @limit) + 1
end
diff --git a/app/models/group.rb b/app/models/group.rb
index 1d6a3a14450..14d088dd38b 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -19,7 +19,6 @@ class Group < Namespace
include BulkMemberAccessLoad
include ChronicDurationAttribute
include RunnerTokenExpirationInterval
- include RunnersTokenPrefixable
extend ::Gitlab::Utils::Override
@@ -120,7 +119,7 @@ class Group < Namespace
add_authentication_token_field :runners_token,
encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required },
- prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
+ prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
after_create :post_create_hook
after_destroy :post_destroy_hook
@@ -676,7 +675,7 @@ class Group < Namespace
override :format_runners_token
def format_runners_token(token)
- "#{runners_token_prefix}#{token}"
+ "#{RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX}#{token}"
end
def project_creation_level
@@ -817,7 +816,9 @@ class Group < Namespace
private
def max_member_access(user_ids)
- max_member_access_for_resource_ids(User, user_ids) do |user_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(User),
+ resource_ids: user_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |user_ids|
members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level)
end
end
@@ -892,6 +893,7 @@ class Group < Namespace
.where(group_member_table[:requested_at].eq(nil))
.where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id]))
.where(group_member_table[:source_type].eq('Namespace'))
+ .where(group_member_table[:state].eq(::Member::STATE_ACTIVE))
.non_minimal_access
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 7e538238cbd..88941df691c 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -37,14 +37,14 @@ class WebHook < ApplicationRecord
!temporarily_disabled? && !permanently_disabled?
end
- def temporarily_disabled?
- return false unless web_hooks_disable_failed?
+ def temporarily_disabled?(ignore_flag: false)
+ return false unless ignore_flag || web_hooks_disable_failed?
disabled_until.present? && disabled_until >= Time.current
end
- def permanently_disabled?
- return false unless web_hooks_disable_failed?
+ def permanently_disabled?(ignore_flag: false)
+ return false unless ignore_flag || web_hooks_disable_failed?
recent_failures > FAILURE_THRESHOLD
end
@@ -106,6 +106,13 @@ class WebHook < ApplicationRecord
save(validate: false)
end
+ def active_state(ignore_flag: false)
+ return :permanently_disabled if permanently_disabled?(ignore_flag: ignore_flag)
+ return :temporarily_disabled if temporarily_disabled?(ignore_flag: ignore_flag)
+
+ :enabled
+ end
+
# @return [Boolean] Whether or not the WebHook is currently throttled.
def rate_limited?
return false unless rate_limit
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
index 2016024b2f4..00e55d0fd89 100644
--- a/app/models/instance_configuration.rb
+++ b/app/models/instance_configuration.rb
@@ -118,7 +118,8 @@ class InstanceConfiguration
group_export_download: application_setting_limit_per_minute(:group_download_export_limit),
group_import: application_setting_limit_per_minute(:group_import_limit),
raw_blob: application_setting_limit_per_minute(:raw_blob_request_limit),
- user_email_lookup: application_setting_limit_per_minute(:user_email_lookup_limit),
+ search_rate_limit: application_setting_limit_per_minute(:search_rate_limit),
+ search_rate_limit_unauthenticated: application_setting_limit_per_minute(:search_rate_limit_unauthenticated),
users_get_by_id: {
enabled: application_settings[:users_get_by_id_limit] > 0,
requests_per_period: application_settings[:users_get_by_id_limit],
diff --git a/app/models/integration.rb b/app/models/integration.rb
index e9cd90649ba..274c16507b7 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -9,10 +9,18 @@ class Integration < ApplicationRecord
include Integrations::HasDataFields
include FromUnion
include EachBatch
+ include IgnorableColumns
+
+ ignore_column :template, remove_with: '15.0', remove_after: '2022-04-22'
+ ignore_column :type, remove_with: '15.0', remove_after: '2022-04-22'
+
+ UnknownType = Class.new(StandardError)
+
+ self.inheritance_column = :type_new
INTEGRATION_NAMES = %w[
asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord
- drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira
+ drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat harbor irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao
].freeze
@@ -37,9 +45,21 @@ class Integration < ApplicationRecord
Integrations::BaseSlashCommands
].freeze
+ SECTION_TYPE_CONNECTION = 'connection'
+
serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
- attribute :type, Gitlab::Integrations::StiType.new
+ attr_encrypted :encrypted_properties_tmp,
+ attribute: :encrypted_properties,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ marshal: true,
+ marshaler: ::Gitlab::Json,
+ encode: false,
+ encode_iv: false
+
+ alias_attribute :type, :type_new
default_value_for :active, false
default_value_for :alert_events, true
@@ -57,6 +77,8 @@ class Integration < ApplicationRecord
default_value_for :wiki_page_events, true
after_initialize :initialize_properties
+ after_initialize :copy_properties_to_encrypted_properties
+ before_save :copy_properties_to_encrypted_properties
after_commit :reset_updated_properties
@@ -74,9 +96,10 @@ class Integration < ApplicationRecord
validate :validate_belongs_to_project_or_group
scope :external_issue_trackers, -> { where(category: 'issue_tracker').active }
- scope :external_wikis, -> { where(type: 'ExternalWikiService').active }
+ scope :by_name, ->(name) { by_type(integration_name_to_type(name)) }
+ scope :external_wikis, -> { by_name(:external_wiki).active }
scope :active, -> { where(active: true) }
- scope :by_type, -> (type) { where(type: type) }
+ scope :by_type, ->(type) { where(type: type) } # INTERNAL USE ONLY: use by_name instead
scope :by_active_flag, -> (flag) { where(active: flag) }
scope :inherit_from_id, -> (id) { where(inherit_from_id: id) }
scope :with_default_settings, -> { where.not(inherit_from_id: nil) }
@@ -99,6 +122,39 @@ class Integration < ApplicationRecord
scope :alert_hooks, -> { where(alert_events: true, active: true) }
scope :deployment, -> { where(category: 'deployment') }
+ class << self
+ private
+
+ attr_writer :field_storage
+
+ def field_storage
+ @field_storage || :properties
+ end
+ end
+
+ # :nocov: Tested on subclasses.
+ def self.field(name, storage: field_storage, **attrs)
+ fields << ::Integrations::Field.new(name: name, **attrs)
+
+ case storage
+ when :properties
+ prop_accessor(name)
+ when :data_fields
+ data_field(name)
+ else
+ raise ArgumentError, "Unknown field storage: #{storage}"
+ end
+ end
+ # :nocov:
+
+ def self.fields
+ @fields ||= []
+ end
+
+ def fields
+ self.class.fields
+ end
+
# Provide convenient accessor methods for each serialized property.
# Also keep track of updated properties in a similar way as ActiveModel::Dirty
def self.prop_accessor(*args)
@@ -112,8 +168,10 @@ class Integration < ApplicationRecord
def #{arg}=(value)
self.properties ||= {}
+ self.encrypted_properties_tmp = properties
updated_properties['#{arg}'] = #{arg} unless #{arg}_changed?
self.properties['#{arg}'] = value
+ self.encrypted_properties_tmp['#{arg}'] = value
end
def #{arg}_changed?
@@ -158,10 +216,6 @@ class Integration < ApplicationRecord
self.supported_events.map { |event| IntegrationsHelper.integration_event_field_name(event) }
end
- def self.supported_event_actions
- %w[]
- end
-
def self.supported_events
%w[commit push tag_push issue confidential_issue merge_request wiki_page]
end
@@ -226,7 +280,7 @@ class Integration < ApplicationRecord
end
# Returns a list of available integration types.
- # Example: ["AsanaService", ...]
+ # Example: ["Integrations::Asana", ...]
def self.available_integration_types(include_project_specific: true, include_dev: true)
available_integration_names(include_project_specific: include_project_specific, include_dev: include_dev).map do
integration_name_to_type(_1)
@@ -234,22 +288,27 @@ class Integration < ApplicationRecord
end
# Returns the model for the given integration name.
- # Example: "asana" => Integrations::Asana
+ # Example: :asana => Integrations::Asana
def self.integration_name_to_model(name)
type = integration_name_to_type(name)
integration_type_to_model(type)
end
# Returns the STI type for the given integration name.
- # Example: "asana" => "AsanaService"
+ # Example: "asana" => "Integrations::Asana"
def self.integration_name_to_type(name)
- "#{name}_service".camelize
+ name = name.to_s
+ if available_integration_names.exclude?(name)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(UnknownType.new(name.inspect))
+ else
+ "Integrations::#{name.camelize}"
+ end
end
# Returns the model for the given STI type.
- # Example: "AsanaService" => Integrations::Asana
+ # Example: "Integrations::Asana" => Integrations::Asana
def self.integration_type_to_model(type)
- Gitlab::Integrations::StiType.new.cast(type).constantize
+ type.constantize
end
private_class_method :integration_type_to_model
@@ -298,7 +357,7 @@ class Integration < ApplicationRecord
from_union([
active.where(instance: true),
active.where(group_id: group_ids, inherit_from_id: nil)
- ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")).group_by(&:type).each do |type, records|
+ ]).order(Arel.sql("type_new ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")).group_by(&:type).each do |type, records|
build_from_integration(records.first, association => scope.id).save
end
end
@@ -330,6 +389,10 @@ class Integration < ApplicationRecord
true
end
+ def activate_disabled_reason
+ nil
+ end
+
def category
read_attribute(:category).to_sym
end
@@ -338,6 +401,12 @@ class Integration < ApplicationRecord
self.properties = {} if has_attribute?(:properties) && properties.nil?
end
+ def copy_properties_to_encrypted_properties
+ self.encrypted_properties_tmp = properties
+ rescue ActiveModel::MissingAttributeError
+ # ignore - in a record built from using a restricted select list
+ end
+
def title
# implement inside child
end
@@ -355,8 +424,7 @@ class Integration < ApplicationRecord
self.class.to_param
end
- def fields
- # implement inside child
+ def sections
[]
end
@@ -371,8 +439,24 @@ class Integration < ApplicationRecord
%w[active]
end
+ # return a hash of columns => values suitable for passing to insert_all
def to_integration_hash
- as_json(methods: :type, except: %w[id instance project_id group_id])
+ column = self.class.attribute_aliases.fetch('type', 'type')
+ copy_properties_to_encrypted_properties
+
+ as_json(except: %w[id instance project_id group_id encrypted_properties_tmp])
+ .merge(column => type)
+ .merge(reencrypt_properties)
+ end
+
+ def reencrypt_properties
+ unless properties.nil? || properties.empty?
+ alg = self.class.encrypted_attributes[:encrypted_properties_tmp][:algorithm]
+ iv = generate_iv(alg)
+ ep = self.class.encrypt(:encrypted_properties_tmp, properties, { iv: iv })
+ end
+
+ { 'encrypted_properties' => ep, 'encrypted_properties_iv' => iv }
end
def to_data_fields_hash
@@ -392,7 +476,10 @@ class Integration < ApplicationRecord
end
def api_field_names
- fields.pluck(:name).grep_v(/password|token|key|title|description/)
+ fields
+ .reject { _1[:type] == 'password' }
+ .pluck(:name)
+ .grep_v(/password|token|key/)
end
def global_fields
@@ -410,10 +497,6 @@ class Integration < ApplicationRecord
end
end
- def configurable_event_actions
- self.class.supported_event_actions
- end
-
def supported_events
self.class.supported_events
end
diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb
index 7949563a1dc..054f0606dd2 100644
--- a/app/models/integrations/asana.rb
+++ b/app/models/integrations/asana.rb
@@ -4,8 +4,6 @@ require 'asana'
module Integrations
class Asana < Integration
- include ActionView::Helpers::UrlHelper
-
prop_accessor :api_key, :restrict_to_branch
validates :api_key, presence: true, if: :activated?
@@ -18,7 +16,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer'
s_('Add commit messages as comments to Asana tasks. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
index 57767c63cf4..c614a9415ab 100644
--- a/app/models/integrations/bamboo.rb
+++ b/app/models/integrations/bamboo.rb
@@ -2,7 +2,6 @@
module Integrations
class Bamboo < BaseCi
- include ActionView::Helpers::UrlHelper
include ReactivelyCached
prepend EnableSslVerification
@@ -36,7 +35,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer'
s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo. You must set up automatic revision labeling and a repository trigger in Bamboo. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index d0d54a92021..d5b6357cb66 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -241,7 +241,6 @@ module Integrations
def notify_for_ref?(data)
return true if data[:object_kind] == 'tag_push'
- return true if data[:object_kind] == 'deployment' && !Feature.enabled?(:chat_notification_deployment_protected_branch_filter, project)
ref = data[:ref] || data.dig(:object_attributes, :ref)
return true if ref.blank? # No need to check protected branches when there is no ref
diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb
index 42a6a3a19c8..458d0199e7a 100644
--- a/app/models/integrations/base_issue_tracker.rb
+++ b/app/models/integrations/base_issue_tracker.rb
@@ -4,10 +4,6 @@ module Integrations
class BaseIssueTracker < Integration
validate :one_issue_tracker, if: :activated?, on: :manual_change
- # TODO: we can probably just delegate as part of
- # https://gitlab.com/gitlab-org/gitlab/issues/29404
- data_field :project_url, :issues_url, :new_issue_url
-
default_value_for :category, 'issue_tracker'
before_validation :handle_properties
@@ -72,14 +68,6 @@ module Integrations
issue_url(iid)
end
- def fields
- [
- { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in the external issue tracker.'), required: true },
- { type: 'text', name: 'issues_url', title: s_('IssueTracker|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true },
- { type: 'text', name: 'new_issue_url', title: s_('IssueTracker|New issue URL'), help: s_('IssueTracker|The URL to create an issue in the external issue tracker.'), required: true }
- ]
- end
-
def initialize_properties
{}
end
@@ -132,8 +120,18 @@ module Integrations
# implement inside child
end
+ def activate_disabled_reason
+ { trackers: other_external_issue_trackers } if other_external_issue_trackers.any?
+ end
+
private
+ def other_external_issue_trackers
+ return [] unless project_level?
+
+ @other_external_issue_trackers ||= project.integrations.external_issue_trackers.where.not(id: id)
+ end
+
def enabled_in_gitlab_config
Gitlab.config.issues_tracker &&
Gitlab.config.issues_tracker.values.any? &&
@@ -145,10 +143,10 @@ module Integrations
end
def one_issue_tracker
- return if template? || instance?
+ return if instance?
return if project.blank?
- if project.integrations.external_issue_trackers.where.not(id: id).any?
+ if other_external_issue_trackers.any?
errors.add(:base, _('Another issue tracker is already in use. Only one issue tracker service can be active at a time'))
end
end
diff --git a/app/models/integrations/bugzilla.rb b/app/models/integrations/bugzilla.rb
index 9251015acb8..74e282f6848 100644
--- a/app/models/integrations/bugzilla.rb
+++ b/app/models/integrations/bugzilla.rb
@@ -2,7 +2,7 @@
module Integrations
class Bugzilla < BaseIssueTracker
- include ActionView::Helpers::UrlHelper
+ include Integrations::HasIssueTrackerFields
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
@@ -15,7 +15,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer'
s_("IssueTracker|Use Bugzilla as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb
index c78fc6eff51..81e6c2411b8 100644
--- a/app/models/integrations/campfire.rb
+++ b/app/models/integrations/campfire.rb
@@ -2,8 +2,6 @@
module Integrations
class Campfire < Integration
- include ActionView::Helpers::UrlHelper
-
prop_accessor :token, :subdomain, :room
validates :token, presence: true, if: :activated?
@@ -16,7 +14,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer'
s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb
index 7f111f482dd..65adce7a8d6 100644
--- a/app/models/integrations/confluence.rb
+++ b/app/models/integrations/confluence.rb
@@ -2,8 +2,6 @@
module Integrations
class Confluence < Integration
- include ActionView::Helpers::UrlHelper
-
VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze
VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze
VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze
@@ -39,7 +37,7 @@ module Integrations
s_(
'ConfluenceService|Your GitLab wiki is still available at %{wiki_link}. To re-enable the link to the GitLab wiki, disable this integration.' %
- { wiki_link: link_to(wiki_url, wiki_url) }
+ { wiki_link: ActionController::Base.helpers.link_to(wiki_url, wiki_url) }
).html_safe
else
s_('ConfluenceService|Link to a Confluence Workspace from the sidebar. Enabling this integration replaces the "Wiki" sidebar link with a link to the Confluence Workspace. The GitLab wiki is still available at the original URL.').html_safe
diff --git a/app/models/integrations/custom_issue_tracker.rb b/app/models/integrations/custom_issue_tracker.rb
index 635a9d093e9..3770e813eaa 100644
--- a/app/models/integrations/custom_issue_tracker.rb
+++ b/app/models/integrations/custom_issue_tracker.rb
@@ -2,7 +2,8 @@
module Integrations
class CustomIssueTracker < BaseIssueTracker
- include ActionView::Helpers::UrlHelper
+ include HasIssueTrackerFields
+
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
def title
@@ -14,7 +15,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer'
s_('IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb
index 21993dd3c43..790e41e5a2a 100644
--- a/app/models/integrations/discord.rb
+++ b/app/models/integrations/discord.rb
@@ -4,8 +4,6 @@ require "discordrb/webhooks"
module Integrations
class Discord < BaseChatNotification
- include ActionView::Helpers::UrlHelper
-
ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze
def title
@@ -21,7 +19,7 @@ module Integrations
end
def help
- docs_link = link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer'
s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb
index 24d343b7cb4..1b86ef73c85 100644
--- a/app/models/integrations/ewm.rb
+++ b/app/models/integrations/ewm.rb
@@ -2,7 +2,7 @@
module Integrations
class Ewm < BaseIssueTracker
- include ActionView::Helpers::UrlHelper
+ include HasIssueTrackerFields
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
@@ -19,7 +19,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer'
s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb
index 2a8d598117b..18c48411e30 100644
--- a/app/models/integrations/external_wiki.rb
+++ b/app/models/integrations/external_wiki.rb
@@ -2,8 +2,6 @@
module Integrations
class ExternalWiki < Integration
- include ActionView::Helpers::UrlHelper
-
prop_accessor :external_wiki_url
validates :external_wiki_url, presence: true, public_url: true, if: :activated?
@@ -33,7 +31,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer'
s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb
new file mode 100644
index 00000000000..49ab97677db
--- /dev/null
+++ b/app/models/integrations/field.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Field
+ SENSITIVE_NAME = %r/token|key|password|passphrase|secret/.freeze
+
+ ATTRIBUTES = %i[
+ section type placeholder required choices value checkbox_label
+ title help
+ non_empty_password_help
+ non_empty_password_title
+ api_only
+ ].freeze
+
+ attr_reader :name
+
+ def initialize(name:, type: 'text', api_only: false, **attributes)
+ @name = name.to_s.freeze
+
+ attributes[:type] = SENSITIVE_NAME.match?(@name) ? 'password' : type
+ attributes[:api_only] = api_only
+ @attributes = attributes.freeze
+ end
+
+ def [](key)
+ return name if key == :name
+
+ value = @attributes[key]
+ return value.call if value.respond_to?(:call)
+
+ value
+ end
+
+ def sensitive?
+ @attributes[:type] == 'password'
+ end
+
+ ATTRIBUTES.each do |name|
+ define_method(name) { self[name] }
+ end
+ end
+end
diff --git a/app/models/integrations/flowdock.rb b/app/models/integrations/flowdock.rb
index 443f61e65dd..476cdc35585 100644
--- a/app/models/integrations/flowdock.rb
+++ b/app/models/integrations/flowdock.rb
@@ -2,8 +2,6 @@
module Integrations
class Flowdock < Integration
- include ActionView::Helpers::UrlHelper
-
prop_accessor :token
validates :token, presence: true, if: :activated?
@@ -16,7 +14,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'flowdock'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'flowdock'), target: '_blank', rel: 'noopener noreferrer'
s_('FlowdockService|Send event notifications from GitLab to Flowdock flows. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb
index 0d6b9fb1019..8c68c9ff95a 100644
--- a/app/models/integrations/hangouts_chat.rb
+++ b/app/models/integrations/hangouts_chat.rb
@@ -2,8 +2,6 @@
module Integrations
class HangoutsChat < BaseChatNotification
- include ActionView::Helpers::UrlHelper
-
def title
'Google Chat'
end
@@ -17,7 +15,7 @@ module Integrations
end
def help
- docs_link = link_to _('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer'
s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb
new file mode 100644
index 00000000000..4c76e418886
--- /dev/null
+++ b/app/models/integrations/harbor.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Harbor < Integration
+ prop_accessor :url, :project_name, :username, :password
+
+ validates :url, public_url: true, presence: true, if: :activated?
+ validates :project_name, presence: true, if: :activated?
+ validates :username, presence: true, if: :activated?
+ validates :password, format: { with: ::Ci::Maskable::REGEX }, if: :activated?
+
+ before_validation :reset_username_and_password
+
+ def title
+ 'Harbor'
+ end
+
+ def description
+ s_("HarborIntegration|Use Harbor as this project's container registry.")
+ end
+
+ def help
+ s_("HarborIntegration|After the Harbor integration is activated, global variables ‘$HARBOR_USERNAME’, ‘$HARBOR_PASSWORD’, ‘$HARBOR_URL’ and ‘$HARBOR_PROJECT’ will be created for CI/CD use.")
+ end
+
+ class << self
+ def to_param
+ name.demodulize.downcase
+ end
+
+ def supported_events
+ []
+ end
+
+ def supported_event_actions
+ []
+ end
+ end
+
+ def test(*_args)
+ client.ping
+ end
+
+ def fields
+ [
+ {
+ type: 'text',
+ name: 'url',
+ title: s_('HarborIntegration|Harbor URL'),
+ placeholder: 'https://demo.goharbor.io',
+ help: s_('HarborIntegration|Base URL of the Harbor instance.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'project_name',
+ title: s_('HarborIntegration|Harbor project name'),
+ help: s_('HarborIntegration|The name of the project in Harbor.')
+ },
+ {
+ type: 'text',
+ name: 'username',
+ title: s_('HarborIntegration|Harbor username'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'password',
+ title: s_('HarborIntegration|Harbor password'),
+ non_empty_password_title: s_('HarborIntegration|Enter Harbor password'),
+ non_empty_password_help: s_('HarborIntegration|Password for your Harbor username.'),
+ required: true
+ }
+ ]
+ end
+
+ def ci_variables
+ return [] unless activated?
+
+ [
+ { key: 'HARBOR_URL', value: url },
+ { key: 'HARBOR_PROJECT', value: project_name },
+ { key: 'HARBOR_USERNAME', value: username },
+ { key: 'HARBOR_PASSWORD', value: password, public: false, masked: true }
+ ]
+ end
+
+ private
+
+ def client
+ @client ||= ::Gitlab::Harbor::Client.new(self)
+ end
+
+ def reset_username_and_password
+ if url_changed? && !password_touched?
+ self.password = nil
+ end
+
+ if url_changed? && !username_touched?
+ self.username = nil
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb
index cea4aa2038d..116d1fb233d 100644
--- a/app/models/integrations/irker.rb
+++ b/app/models/integrations/irker.rb
@@ -4,8 +4,6 @@ require 'uri'
module Integrations
class Irker < Integration
- include ActionView::Helpers::UrlHelper
-
prop_accessor :server_host, :server_port, :default_irc_uri
prop_accessor :recipients, :channels
boolean_accessor :colorize_messages
@@ -44,7 +42,7 @@ module Integrations
end
def fields
- recipients_docs_link = link_to s_('IrkerService|How to enter channels or users?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'enter-irker-recipients'), target: '_blank', rel: 'noopener noreferrer'
+ recipients_docs_link = ActionController::Base.helpers.link_to s_('IrkerService|How to enter channels or users?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'enter-irker-recipients'), target: '_blank', rel: 'noopener noreferrer'
[
{ type: 'text', name: 'server_host', placeholder: 'localhost', title: s_('IrkerService|Server host (optional)'),
help: s_('IrkerService|irker daemon hostname (defaults to localhost).') },
@@ -61,7 +59,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'set-up-an-irker-daemon'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'set-up-an-irker-daemon'), target: '_blank', rel: 'noopener noreferrer'
s_('IrkerService|Send update messages to an irker server. Before you can use this, you need to set up the irker daemon. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb
index 5ea92170c26..32f11ee23eb 100644
--- a/app/models/integrations/jenkins.rb
+++ b/app/models/integrations/jenkins.rb
@@ -3,7 +3,7 @@
module Integrations
class Jenkins < BaseCi
include HasWebHook
- include ActionView::Helpers::UrlHelper
+
prepend EnableSslVerification
extend Gitlab::Utils::Override
@@ -65,7 +65,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer'
s_('Run CI/CD pipelines with Jenkins when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index 966ad07afad..74ece57000f 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -15,6 +15,9 @@ module Integrations
ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' }.freeze
ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' }.freeze
+ SECTION_TYPE_JIRA_TRIGGER = 'jira_trigger'
+ SECTION_TYPE_JIRA_ISSUES = 'jira_issues'
+
validates :url, public_url: true, presence: true, if: :activated?
validates :api_url, public_url: true, allow_blank: true
validates :username, presence: true, if: :activated?
@@ -28,11 +31,6 @@ module Integrations
# We should use username/password for Jira Server and email/api_token for Jira Cloud,
# for more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/49936.
- # TODO: we can probably just delegate as part of
- # https://gitlab.com/gitlab-org/gitlab/issues/29404
- data_field :username, :password, :url, :api_url, :jira_issue_transition_automatic, :jira_issue_transition_id, :project_key, :issues_enabled,
- :vulnerabilities_enabled, :vulnerabilities_issuetype
-
before_validation :reset_password
after_commit :update_deployment_type, on: [:create, :update], if: :update_deployment_type?
@@ -41,16 +39,50 @@ module Integrations
all_details: 2
}
+ self.field_storage = :data_fields
+
+ field :url,
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('JiraService|Web URL') },
+ help: -> { s_('JiraService|Base URL of the Jira instance.') },
+ placeholder: 'https://jira.example.com'
+
+ field :api_url,
+ section: SECTION_TYPE_CONNECTION,
+ title: -> { s_('JiraService|Jira API URL') },
+ help: -> { s_('JiraService|If different from Web URL.') }
+
+ field :username,
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('JiraService|Username or Email') },
+ help: -> { s_('JiraService|Use a username for server version and an email for cloud version.') }
+
+ field :password,
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('JiraService|Password or API token') },
+ non_empty_password_title: -> { s_('JiraService|Enter new password or API token') },
+ non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') },
+ help: -> { s_('JiraService|Use a password for server version and an API token for cloud version.') }
+
+ # TODO: we can probably just delegate as part of
+ # https://gitlab.com/gitlab-org/gitlab/issues/29404
+ # These fields are API only, so no field definition is required.
+ data_field :jira_issue_transition_automatic
+ data_field :jira_issue_transition_id
+ data_field :project_key
+ data_field :issues_enabled
+ data_field :vulnerabilities_enabled
+ data_field :vulnerabilities_issuetype
+
# When these are false GitLab does not create cross reference
# comments on Jira except when an issue gets transitioned.
def self.supported_events
%w(commit merge_request)
end
- def self.supported_event_actions
- %w(comment)
- end
-
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
def self.reference_pattern(only_long: true)
@reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/
@@ -111,8 +143,8 @@ module Integrations
end
def help
- jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('integration/jira/index.html') }
- s_("JiraService|You need to configure Jira before enabling this integration. For more details, read the %{jira_doc_link_start}Jira integration documentation%{link_end}.") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe }
+ jira_doc_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('integration/jira/index.html') }
+ s_("JiraService|You must configure Jira before enabling this integration. %{jira_doc_link_start}Learn more.%{link_end}") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe }
end
def title
@@ -127,39 +159,32 @@ module Integrations
'jira'
end
- def fields
- [
- {
- type: 'text',
- name: 'url',
- title: s_('JiraService|Web URL'),
- placeholder: 'https://jira.example.com',
- help: s_('JiraService|Base URL of the Jira instance.'),
- required: true
- },
- {
- type: 'text',
- name: 'api_url',
- title: s_('JiraService|Jira API URL'),
- help: s_('JiraService|If different from Web URL.')
- },
+ def sections
+ jira_issues_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('integration/jira/issues.html') }
+
+ sections = [
{
- type: 'text',
- name: 'username',
- title: s_('JiraService|Username or Email'),
- help: s_('JiraService|Use a username for server version and an email for cloud version.'),
- required: true
+ type: SECTION_TYPE_CONNECTION,
+ title: s_('Integrations|Connection details'),
+ description: help
},
{
- type: 'password',
- name: 'password',
- title: s_('JiraService|Password or API token'),
- non_empty_password_title: s_('JiraService|Enter new password or API token'),
- non_empty_password_help: s_('JiraService|Leave blank to use your current password or API token.'),
- help: s_('JiraService|Use a password for server version and an API token for cloud version.'),
- required: true
+ type: SECTION_TYPE_JIRA_TRIGGER,
+ title: _('Trigger'),
+ description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.')
}
]
+
+ # Jira issues is currently only configurable on the project level.
+ if project_level?
+ sections.push({
+ type: SECTION_TYPE_JIRA_ISSUES,
+ title: _('Issues'),
+ description: s_('JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues. %{jira_issues_link_start}Learn more.%{link_end}') % { jira_issues_link_start: jira_issues_link_start, link_end: '</a>'.html_safe }
+ })
+ end
+
+ sections
end
def web_url(path = nil, **params)
@@ -180,17 +205,12 @@ module Integrations
url.to_s
end
- override :project_url
- def project_url
- web_url
- end
+ alias_method :project_url, :web_url
- override :issues_url
def issues_url
web_url('browse/:id')
end
- override :new_issue_url
def new_issue_url
web_url('secure/CreateIssue!default.jspa')
end
diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb
index 07a5086b8e9..d9ccbb7ea34 100644
--- a/app/models/integrations/mattermost.rb
+++ b/app/models/integrations/mattermost.rb
@@ -3,7 +3,6 @@
module Integrations
class Mattermost < BaseChatNotification
include SlackMattermostNotifier
- include ActionView::Helpers::UrlHelper
def title
s_('Mattermost notifications')
@@ -18,7 +17,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer'
s_('Send notifications about project events to Mattermost channels. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb
index 24cfd51eb55..5b9ac023b7e 100644
--- a/app/models/integrations/pivotaltracker.rb
+++ b/app/models/integrations/pivotaltracker.rb
@@ -2,7 +2,6 @@
module Integrations
class Pivotaltracker < Integration
- include ActionView::Helpers::UrlHelper
API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits'
prop_accessor :token, :restrict_to_branch
@@ -17,7 +16,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pivotal_tracker'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pivotal_tracker'), target: '_blank', rel: 'noopener noreferrer'
s_('Add commit messages as comments to Pivotal Tracker stories. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
index 5746343c31c..2e275dab91b 100644
--- a/app/models/integrations/prometheus.rb
+++ b/app/models/integrations/prometheus.rb
@@ -115,7 +115,6 @@ module Integrations
end
def prometheus_available?
- return false if template?
return false unless project
project.all_clusters.enabled.eager_load(:integration_prometheus).any? do |cluster|
diff --git a/app/models/integrations/redmine.rb b/app/models/integrations/redmine.rb
index 990b538f294..bc2a64b0848 100644
--- a/app/models/integrations/redmine.rb
+++ b/app/models/integrations/redmine.rb
@@ -2,7 +2,8 @@
module Integrations
class Redmine < BaseIssueTracker
- include ActionView::Helpers::UrlHelper
+ include Integrations::HasIssueTrackerFields
+
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
def title
@@ -14,7 +15,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer'
s_('IssueTracker|Use Redmine as the issue tracker. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb
index 7660eda6f83..345dd98cbc1 100644
--- a/app/models/integrations/webex_teams.rb
+++ b/app/models/integrations/webex_teams.rb
@@ -2,8 +2,6 @@
module Integrations
class WebexTeams < BaseChatNotification
- include ActionView::Helpers::UrlHelper
-
def title
s_("WebexTeamsService|Webex Teams")
end
@@ -17,7 +15,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer'
s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb
index 10531717f11..ab6e1da27f8 100644
--- a/app/models/integrations/youtrack.rb
+++ b/app/models/integrations/youtrack.rb
@@ -2,7 +2,7 @@
module Integrations
class Youtrack < BaseIssueTracker
- include ActionView::Helpers::UrlHelper
+ include Integrations::HasIssueTrackerFields
validates :project_url, :issues_url, presence: true, public_url: true, if: :activated?
@@ -24,7 +24,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer'
s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb
index 493d42cc40b..c33df465fde 100644
--- a/app/models/integrations/zentao.rb
+++ b/app/models/integrations/zentao.rb
@@ -11,7 +11,6 @@ module Integrations
validates :api_token, presence: true, if: :activated?
validates :zentao_product_xid, presence: true, if: :activated?
- # License Level: EEP_FEATURES
def self.issues_license_available?(project)
project&.licensed_feature_available?(:zentao_issues_integration)
end
@@ -48,10 +47,6 @@ module Integrations
%w()
end
- def self.supported_event_actions
- %w()
- end
-
def fields
[
{
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 68ea6cb3abc..75727fff2cd 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -24,6 +24,7 @@ class Issue < ApplicationRecord
include Todoable
include FromUnion
include EachBatch
+ include PgFullTextSearchable
extend ::Gitlab::Utils::Override
@@ -77,6 +78,7 @@ class Issue < ApplicationRecord
end
end
+ has_one :search_data, class_name: 'Issues::SearchData'
has_one :issuable_severity
has_one :sentry_issue
has_one :alert_management_alert, class_name: 'AlertManagement::Alert'
@@ -102,6 +104,8 @@ class Issue < ApplicationRecord
alias_attribute :external_author, :service_desk_reply_to
+ pg_full_text_searchable columns: [{ name: 'title', weight: 'A' }, { name: 'description', weight: 'B' }]
+
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
scope :not_in_projects, ->(project_ids) { where.not(project_id: project_ids) }
@@ -233,6 +237,11 @@ class Issue < ApplicationRecord
def order_upvotes_asc
reorder(upvotes_count: :asc)
end
+
+ override :pg_full_text_search
+ def pg_full_text_search(search_term)
+ super.where('issue_search_data.project_id = issues.project_id')
+ end
end
def next_object_by_relative_position(ignoring: nil, order: :asc)
@@ -611,6 +620,11 @@ class Issue < ApplicationRecord
private
+ override :persist_pg_full_text_search_vector
+ def persist_pg_full_text_search_vector(search_vector)
+ Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id))
+ end
+
def spammable_attribute_changed?
title_changed? ||
description_changed? ||
diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb
index 920586cc1ba..1bd34aa0083 100644
--- a/app/models/issue_link.rb
+++ b/app/models/issue_link.rb
@@ -2,46 +2,17 @@
class IssueLink < ApplicationRecord
include FromUnion
+ include IssuableLink
belongs_to :source, class_name: 'Issue'
belongs_to :target, class_name: 'Issue'
- validates :source, presence: true
- validates :target, presence: true
- validates :source, uniqueness: { scope: :target_id, message: 'is already related' }
- validate :check_self_relation
- validate :check_opposite_relation
-
scope :for_source_issue, ->(issue) { where(source_id: issue.id) }
scope :for_target_issue, ->(issue) { where(target_id: issue.id) }
- TYPE_RELATES_TO = 'relates_to'
- TYPE_BLOCKS = 'blocks'
- # we don't store is_blocked_by in the db but need it for displaying the relation
- # from the target (used in IssueLink.inverse_link_type)
- TYPE_IS_BLOCKED_BY = 'is_blocked_by'
-
- enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 }
-
- def self.inverse_link_type(type)
- type
- end
-
- private
-
- def check_self_relation
- return unless source && target
-
- if source == target
- errors.add(:source, 'cannot be related to itself')
- end
- end
-
- def check_opposite_relation
- return unless source && target
-
- if IssueLink.find_by(source: target, target: source)
- errors.add(:source, 'is already related to this issue')
+ class << self
+ def issuable_type
+ :issue
end
end
end
diff --git a/app/models/issues/search_data.rb b/app/models/issues/search_data.rb
new file mode 100644
index 00000000000..0eda292796d
--- /dev/null
+++ b/app/models/issues/search_data.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Issues
+ class SearchData < ApplicationRecord
+ extend SuppressCompositePrimaryKeyWarning
+
+ self.table_name = 'issue_search_data'
+
+ belongs_to :issue
+ end
+end
diff --git a/app/models/label.rb b/app/models/label.rb
index 0ebbb5b9bd3..4c9f071f43a 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -12,8 +12,9 @@ class Label < ApplicationRecord
cache_markdown_field :description, pipeline: :single_line
- DEFAULT_COLOR = '#6699cc'
+ DEFAULT_COLOR = ::Gitlab::Color.of('#6699cc')
+ attribute :color, ::Gitlab::Database::Type::Color.new
default_value_for :color, DEFAULT_COLOR
has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -22,9 +23,9 @@ class Label < ApplicationRecord
has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
- before_validation :strip_whitespace_from_title_and_color
+ before_validation :strip_whitespace_from_title
- validates :color, color: true, allow_blank: false
+ validates :color, color: true, presence: true
# Don't allow ',' for label titles
validates :title, presence: true, format: { with: /\A[^,]+\z/ }
@@ -212,15 +213,23 @@ class Label < ApplicationRecord
end
def text_color
- LabelsHelper.text_color_for_bg(self.color)
+ color.contrast
end
def title=(value)
- write_attribute(:title, sanitize_value(value)) if value.present?
+ if value.blank?
+ super
+ else
+ write_attribute(:title, sanitize_value(value))
+ end
end
def description=(value)
- write_attribute(:description, sanitize_value(value)) if value.present?
+ if value.blank?
+ super
+ else
+ write_attribute(:description, sanitize_value(value))
+ end
end
##
@@ -285,8 +294,8 @@ class Label < ApplicationRecord
CGI.unescapeHTML(Sanitize.clean(value.to_s))
end
- def strip_whitespace_from_title_and_color
- %w(color title).each { |attr| self[attr] = self[attr]&.strip }
+ def strip_whitespace_from_title
+ self[:title] = title&.strip
end
end
diff --git a/app/models/lfs_download_object.rb b/app/models/lfs_download_object.rb
index 319499fd1b7..3df6742fbc9 100644
--- a/app/models/lfs_download_object.rb
+++ b/app/models/lfs_download_object.rb
@@ -4,6 +4,7 @@ class LfsDownloadObject
include ActiveModel::Validations
attr_accessor :oid, :size, :link, :headers
+
delegate :sanitized_url, :credentials, to: :sanitized_uri
validates :oid, format: { with: /\A\h{64}\z/ }
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 3a449055bc1..3e19f294253 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -94,9 +94,9 @@ class ProjectMember < Member
override :access_level_inclusion
def access_level_inclusion
- return if access_level.in?(Gitlab::Access.values)
-
- errors.add(:access_level, "is not included in the list")
+ unless access_level.in?(Gitlab::Access.all_values)
+ errors.add(:access_level, "is not included in the list")
+ end
end
override :refresh_member_authorized_projects
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 29540cbde2f..854325e1fcd 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1016,8 +1016,24 @@ class MergeRequest < ApplicationRecord
merge_request_diff.persisted? || create_merge_request_diff
end
- def create_merge_request_diff
+ def eager_fetch_ref!
+ return unless valid?
+
+ # has_internal_id normally attempts to allocate the iid in the
+ # before_create hook, but we need the iid to be available before
+ # that to fetch the ref into the target project.
+ track_target_project_iid!
+ ensure_target_project_iid!
+
fetch_ref!
+ # Prevent the after_create hook from fetching the source branch again.
+ @skip_fetch_ref = true
+ end
+
+ def create_merge_request_diff
+ # Callers such as MergeRequests::BuildService may not call eager_fetch_ref!. Just
+ # in case they haven't, we fetch the ref.
+ fetch_ref! unless skip_fetch_ref
# n+1: https://gitlab.com/gitlab-org/gitlab/-/issues/19377
Gitlab::GitalyClient.allow_n_plus_1_calls do
@@ -1136,15 +1152,20 @@ class MergeRequest < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def mergeable_state?(skip_ci_check: false, skip_discussions_check: false)
- return false unless open?
- return false if work_in_progress?
- return false if broken?
- return false unless skip_discussions_check || mergeable_discussions_state?
-
if Feature.enabled?(:improved_mergeability_checks, self.project, default_enabled: :yaml)
- additional_checks = MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: { skip_ci_check: skip_ci_check })
+ additional_checks = MergeRequests::Mergeability::RunChecksService.new(
+ merge_request: self,
+ params: {
+ skip_ci_check: skip_ci_check,
+ skip_discussions_check: skip_discussions_check
+ }
+ )
additional_checks.execute.all?(&:success?)
else
+ return false unless open?
+ return false if draft?
+ return false if broken?
+ return false unless skip_discussions_check || mergeable_discussions_state?
return false unless skip_ci_check || mergeable_ci_state?
true
@@ -1921,10 +1942,18 @@ class MergeRequest < ApplicationRecord
merge_request_assignees.find_by(user_id: user.id)
end
+ def merge_request_assignees_with(user_ids)
+ merge_request_assignees.where(user_id: user_ids)
+ end
+
def find_reviewer(user)
merge_request_reviewers.find_by(user_id: user.id)
end
+ def merge_request_reviewers_with(user_ids)
+ merge_request_reviewers.where(user_id: user_ids)
+ end
+
def enabled_reports
{
sast: report_type_enabled?(:sast),
@@ -1950,6 +1979,8 @@ class MergeRequest < ApplicationRecord
private
+ attr_accessor :skip_fetch_ref
+
def set_draft_status
self.draft = draft?
end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 2c95cc2672c..86da29dd27a 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -35,6 +35,7 @@ class Milestone < ApplicationRecord
scope :with_api_entity_associations, -> { preload(project: [:project_feature, :route, namespace: :route]) }
scope :order_by_dates_and_title, -> { order(due_date: :asc, start_date: :asc, title: :asc) }
+ validates :title, presence: true
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
validate :uniqueness_of_title, if: :title_changed?
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 5c55f4d3def..ffaeb2071f6 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -117,6 +117,7 @@ class Namespace < ApplicationRecord
before_create :sync_share_with_group_lock_with_parent
before_update :sync_share_with_group_lock_with_parent, if: :parent_changed?
after_update :force_share_with_group_lock_on_descendants, if: -> { saved_change_to_share_with_group_lock? && share_with_group_lock? }
+ after_update :expire_first_auto_devops_config_cache, if: -> { saved_change_to_auto_devops_enabled? }
# Legacy Storage specific hooks
@@ -401,7 +402,11 @@ class Namespace < ApplicationRecord
return { scope: :group, status: auto_devops_enabled } unless auto_devops_enabled.nil?
strong_memoize(:first_auto_devops_config) do
- if has_parent?
+ if has_parent? && cache_first_auto_devops_config?
+ Rails.cache.fetch(first_auto_devops_config_cache_key_for(id), expires_in: 1.day) do
+ parent.first_auto_devops_config
+ end
+ elsif has_parent?
parent.first_auto_devops_config
else
{ scope: :instance, status: Gitlab::CurrentSettings.auto_devops_enabled? }
@@ -509,10 +514,6 @@ class Namespace < ApplicationRecord
Feature.enabled?(:block_issue_repositioning, self, type: :ops, default_enabled: :yaml)
end
- def project_namespace_creation_enabled?
- Feature.enabled?(:create_project_namespace_on_project_create, self, default_enabled: :yaml)
- end
-
def storage_enforcement_date
# should return something like Date.new(2022, 02, 03)
# TBD: https://gitlab.com/gitlab-org/gitlab/-/issues/350632
@@ -621,6 +622,20 @@ class Namespace < ApplicationRecord
.update_all(share_with_group_lock: true)
end
+ def expire_first_auto_devops_config_cache
+ return unless cache_first_auto_devops_config?
+
+ descendants_to_expire = self_and_descendants.as_ids
+ return if descendants_to_expire.load.empty?
+
+ keys = descendants_to_expire.map { |group| first_auto_devops_config_cache_key_for(group.id) }
+ Rails.cache.delete_multi(keys)
+ end
+
+ def cache_first_auto_devops_config?
+ ::Feature.enabled?(:namespaces_cache_first_auto_devops_config, default_enabled: :yaml)
+ end
+
def write_projects_repository_config
all_projects.find_each do |project|
project.set_full_path
@@ -638,6 +653,13 @@ class Namespace < ApplicationRecord
Namespaces::SyncEvent.enqueue_worker
end
end
+
+ def first_auto_devops_config_cache_key_for(group_id)
+ return "namespaces:{first_auto_devops_config}:#{group_id}" unless sync_traversal_ids?
+
+ # Use SHA2 of `traversal_ids` to account for moving a namespace within the same root ancestor hierarchy.
+ "namespaces:{#{traversal_ids.first}}:first_auto_devops_config:#{group_id}:#{Digest::SHA2.hexdigest(traversal_ids.join(' '))}"
+ end
end
Namespace.prepend_mod_with('Namespace')
diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb
index 34086a8af5d..d2de85b5dd4 100644
--- a/app/models/namespace/traversal_hierarchy.rb
+++ b/app/models/namespace/traversal_hierarchy.rb
@@ -31,15 +31,16 @@ class Namespace
# ActiveRecord. https://github.com/rails/rails/issues/13496
# Ideally it would be:
# `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')`
- sql = """
- UPDATE namespaces
- SET traversal_ids = cte.traversal_ids
- FROM (#{recursive_traversal_ids}) as cte
- WHERE namespaces.id = cte.id
- AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
- """
+ sql = <<-SQL
+ UPDATE namespaces
+ SET traversal_ids = cte.traversal_ids
+ FROM (#{recursive_traversal_ids}) as cte
+ WHERE namespaces.id = cte.id
+ AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
+ SQL
+
Namespace.transaction do
- @root.lock!
+ @root.lock!("FOR NO KEY UPDATE")
Namespace.connection.exec_query(sql)
end
rescue ActiveRecord::Deadlocked
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index 99a5b8cb063..1963745cf4d 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -44,22 +44,15 @@ module Namespaces
included do
before_update :lock_both_roots, if: -> { sync_traversal_ids? && parent_id_changed? }
after_update :sync_traversal_ids, if: -> { sync_traversal_ids? && saved_change_to_parent_id? }
- # sync traversal_ids on namespace create, which can happen quite early within a transaction, thus keeping the lock on root namespace record
- # for a relatively long time, e.g. creating the project namespace when a project is being created.
- after_create :sync_traversal_ids, if: -> { sync_traversal_ids? && !sync_traversal_ids_before_commit? }
# This uses rails internal before_commit API to sync traversal_ids on namespace create, right before transaction is committed.
# This helps reduce the time during which the root namespace record is locked to ensure updated traversal_ids are valid
- before_commit :sync_traversal_ids, on: [:create], if: -> { sync_traversal_ids? && sync_traversal_ids_before_commit? }
+ before_commit :sync_traversal_ids, on: [:create], if: -> { sync_traversal_ids? }
end
def sync_traversal_ids?
Feature.enabled?(:sync_traversal_ids, root_ancestor, default_enabled: :yaml)
end
- def sync_traversal_ids_before_commit?
- Feature.enabled?(:sync_traversal_ids_before_commit, root_ancestor, default_enabled: :yaml)
- end
-
def use_traversal_ids?
return false unless Feature.enabled?(:use_traversal_ids, default_enabled: :yaml)
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index 09d69a5f77a..0cac4c9143a 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -126,36 +126,26 @@ module Namespaces
end
def self_and_descendants_with_comparison_operators(include_self: true)
- base = all.select(
- :traversal_ids,
- 'LEAD (namespaces.traversal_ids, 1) OVER (ORDER BY namespaces.traversal_ids ASC) next_traversal_ids'
- )
+ base = all.select(:traversal_ids)
base_cte = Gitlab::SQL::CTE.new(:descendants_base_cte, base)
namespaces = Arel::Table.new(:namespaces)
# Bound the search space to ourselves (optional) and descendants.
#
- # WHERE (base_cte.next_traversal_ids IS NULL OR base_cte.next_traversal_ids > namespaces.traversal_ids)
- # AND next_traversal_ids_sibling(base_cte.traversal_ids) > namespaces.traversal_ids
+ # WHERE next_traversal_ids_sibling(base_cte.traversal_ids) > namespaces.traversal_ids
records = unscoped
+ .distinct
+ .with(base_cte.to_arel)
.from([base_cte.table, namespaces])
- .where(base_cte.table[:next_traversal_ids].eq(nil).or(base_cte.table[:next_traversal_ids].gt(namespaces[:traversal_ids])))
.where(next_sibling_func(base_cte.table[:traversal_ids]).gt(namespaces[:traversal_ids]))
# AND base_cte.traversal_ids <= namespaces.traversal_ids
- records = if include_self
- records.where(base_cte.table[:traversal_ids].lteq(namespaces[:traversal_ids]))
- else
- records.where(base_cte.table[:traversal_ids].lt(namespaces[:traversal_ids]))
- end
-
- records_cte = Gitlab::SQL::CTE.new(:descendants_cte, records)
-
- unscoped
- .unscope(where: [:type])
- .with(base_cte.to_arel, records_cte.to_arel)
- .from(records_cte.alias_to(namespaces))
+ if include_self
+ records.where(base_cte.table[:traversal_ids].lteq(namespaces[:traversal_ids]))
+ else
+ records.where(base_cte.table[:traversal_ids].lt(namespaces[:traversal_ids]))
+ end
end
def next_sibling_func(*args)
diff --git a/app/models/note.rb b/app/models/note.rb
index a84da066968..4f2e7ebe2c5 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -609,7 +609,6 @@ class Note < ApplicationRecord
def show_outdated_changes?
return false unless for_merge_request?
- return false unless Feature.enabled?(:display_outdated_line_diff, noteable.source_project, default_enabled: :yaml)
return false unless system?
return false unless change_position&.line_range
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index fc7c348dfdb..ad8140ac684 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -49,6 +49,7 @@ class Packages::PackageFile < ApplicationRecord
scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) }
scope :preload_debian_file_metadata, -> { preload(:debian_file_metadatum) }
scope :preload_helm_file_metadata, -> { preload(:helm_file_metadatum) }
+ scope :order_id_asc, -> { order(id: :asc) }
scope :for_rubygem_with_file_name, ->(project, file_name) do
joins(:package).merge(project.packages.rubygems).with_file_name(file_name)
diff --git a/app/models/packages/pypi/metadatum.rb b/app/models/packages/pypi/metadatum.rb
index 2e4d61eaf53..ff247fedb59 100644
--- a/app/models/packages/pypi/metadatum.rb
+++ b/app/models/packages/pypi/metadatum.rb
@@ -6,7 +6,7 @@ class Packages::Pypi::Metadatum < ApplicationRecord
belongs_to :package, -> { where(package_type: :pypi) }, inverse_of: :pypi_metadatum
validates :package, presence: true
- validates :required_python, length: { maximum: 255 }, allow_blank: true
+ validates :required_python, length: { maximum: 255 }, allow_nil: false
validate :pypi_package_type
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 2f515f3443d..021ff789b13 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -34,6 +34,7 @@ class PersonalAccessToken < ApplicationRecord
scope :order_expires_at_asc, -> { reorder(expires_at: :asc) }
scope :order_expires_at_desc, -> { reorder(expires_at: :desc) }
scope :project_access_token, -> { includes(:user).where(user: { user_type: :project_bot }) }
+ scope :owner_is_human, -> { includes(:user).where(user: { user_type: :human }) }
validates :scopes, presence: true
validate :validate_scopes
diff --git a/app/models/preloaders/environments/deployment_preloader.rb b/app/models/preloaders/environments/deployment_preloader.rb
index fcf892698bb..251d1837f19 100644
--- a/app/models/preloaders/environments/deployment_preloader.rb
+++ b/app/models/preloaders/environments/deployment_preloader.rb
@@ -21,11 +21,13 @@ module Preloaders
def load_deployment_association(association_name, association_attributes)
return unless environments.present?
- union_arg = environments.inject([]) do |result, environment|
- result << environment.association(association_name).scope
- end
-
- union_sql = Deployment.from_union(union_arg).to_sql
+ # Not using Gitlab::SQL::Union as `order_by` in the SQL constructed is ignored.
+ # See:
+ # 1) https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/sql/union.rb#L7
+ # 2) https://gitlab.com/gitlab-org/gitlab/-/issues/353966#note_860928647
+ union_sql = environments.map do |environment|
+ "(#{environment.association(association_name).scope.to_sql})"
+ end.join(' UNION ')
deployments = Deployment
.from("(#{union_sql}) #{::Deployment.table_name}")
@@ -34,8 +36,16 @@ module Preloaders
deployments_by_environment_id = deployments.index_by(&:environment_id)
environments.each do |environment|
- environment.association(association_name).target = deployments_by_environment_id[environment.id]
+ associated_deployment = deployments_by_environment_id[environment.id]
+
+ environment.association(association_name).target = associated_deployment
environment.association(association_name).loaded!
+
+ if associated_deployment
+ # `last?` in DeploymentEntity requires this environment to be loaded
+ associated_deployment.association(:environment).target = environment
+ associated_deployment.association(:environment).loaded!
+ end
end
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index f89e616a5ca..155ebe88d33 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -38,7 +38,7 @@ class Project < ApplicationRecord
include GitlabRoutingHelper
include BulkMemberAccessLoad
include RunnerTokenExpirationInterval
- include RunnersTokenPrefixable
+ include BlocksUnsafeSerialization
extend Gitlab::Cache::RequestCache
extend Gitlab::Utils::Override
@@ -196,6 +196,7 @@ class Project < ApplicationRecord
has_one :external_wiki_integration, class_name: 'Integrations::ExternalWiki'
has_one :flowdock_integration, class_name: 'Integrations::Flowdock'
has_one :hangouts_chat_integration, class_name: 'Integrations::HangoutsChat'
+ has_one :harbor_integration, class_name: 'Integrations::Harbor'
has_one :irker_integration, class_name: 'Integrations::Irker'
has_one :jenkins_integration, class_name: 'Integrations::Jenkins'
has_one :jira_integration, class_name: 'Integrations::Jira'
@@ -344,22 +345,18 @@ class Project < ApplicationRecord
has_many :stages, class_name: 'Ci::Stage', inverse_of: :project
has_many :ci_refs, class_name: 'Ci::Ref', inverse_of: :project
- # Ci::Build objects store data on the file system such as artifact files and
- # 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 :builds, class_name: 'Ci::Build', inverse_of: :project
has_many :processables, class_name: 'Ci::Processable', inverse_of: :project
- has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
+ has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks, dependent: :restrict_with_error
has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project
- has_many :job_artifacts, class_name: 'Ci::JobArtifact'
- has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :project
+ has_many :job_artifacts, class_name: 'Ci::JobArtifact', dependent: :restrict_with_error
+ has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :project, dependent: :restrict_with_error
has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project
has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
has_many :variables, class_name: 'Ci::Variable'
has_many :triggers, class_name: 'Ci::Trigger'
- has_many :secure_files, class_name: 'Ci::SecureFile'
+ has_many :secure_files, class_name: 'Ci::SecureFile', dependent: :restrict_with_error
has_many :environments
has_many :environments_for_dashboard, -> { from(with_rank.unfoldered.available, :environments).where('rank <= 3') }, class_name: 'Environment'
has_many :deployments
@@ -462,7 +459,7 @@ class Project < ApplicationRecord
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
delegate :add_user, :add_users, to: :team
- delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, to: :team
+ delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role, to: :team
delegate :group_runners_enabled, :group_runners_enabled=, to: :ci_cd_settings, allow_nil: true
delegate :root_ancestor, to: :namespace, allow_nil: true
delegate :last_pipeline, to: :commit, allow_nil: true
@@ -501,11 +498,15 @@ class Project < ApplicationRecord
presence: true,
project_path: true,
length: { maximum: 255 }
+ validates :path,
+ format: { with: Gitlab::Regex.oci_repository_path_regex,
+ message: Gitlab::Regex.oci_repository_path_regex_message },
+ if: :path_changed?
validates :project_feature, presence: true
validates :namespace, presence: true
- validates :project_namespace, presence: true, on: :create, if: -> { self.namespace && self.root_namespace.project_namespace_creation_enabled? }
+ validates :project_namespace, presence: true, on: :create, if: -> { self.namespace }
validates :project_namespace, presence: true, on: :update, if: -> { self.project_namespace_id_changed?(to: nil) }
validates :name, uniqueness: { scope: :namespace_id }
validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS },
@@ -529,6 +530,7 @@ class Project < ApplicationRecord
# Scopes
scope :pending_delete, -> { where(pending_delete: true) }
scope :without_deleted, -> { where(pending_delete: false) }
+ scope :not_hidden, -> { where(hidden: false) }
scope :not_aimed_for_deletion, -> { where(marked_for_deletion_at: nil).without_deleted }
scope :with_storage_feature, ->(feature) do
@@ -1006,10 +1008,6 @@ class Project < ApplicationRecord
Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true)
end
- def context_commits_enabled?
- Feature.enabled?(:context_commits, self.group, default_enabled: :yaml)
- end
-
# LFS and hashed repository storage are required for using Design Management.
def design_management_enabled?
lfs_enabled? && hashed_storage?(:repository)
@@ -1565,14 +1563,17 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def execute_hooks(data, hooks_scope = :push_hooks)
run_after_commit_or_now do
- hooks.hooks_for(hooks_scope).select_active(hooks_scope, data).each do |hook|
- hook.async_execute(data, hooks_scope.to_s)
- end
+ triggered_hooks(hooks_scope, data).execute
SystemHooksService.new.execute_hooks(data, hooks_scope)
end
end
# rubocop: enable CodeReuse/ServiceClass
+ def triggered_hooks(hooks_scope, data)
+ triggered = ::Projects::TriggeredHooks.new(hooks_scope, data)
+ triggered.add_hooks(hooks)
+ end
+
def execute_integrations(data, hooks_scope = :push_hooks)
# Call only service hooks that are active for this scope
run_after_commit_or_now do
@@ -1876,13 +1877,9 @@ class Project < ApplicationRecord
ensure_runners_token!
end
- def runners_token_prefix
- RUNNERS_TOKEN_PREFIX
- end
-
override :format_runners_token
def format_runners_token(token)
- "#{runners_token_prefix}#{token}"
+ "#{RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX}#{token}"
end
def pages_deployed?
@@ -1938,12 +1935,12 @@ class Project < ApplicationRecord
.delete_all
end
- def mark_pages_as_deployed(artifacts_archive: nil)
- ensure_pages_metadatum.update!(deployed: true, artifacts_archive: artifacts_archive)
+ def mark_pages_as_deployed
+ ensure_pages_metadatum.update!(deployed: true)
end
def mark_pages_as_not_deployed
- ensure_pages_metadatum.update!(deployed: false, artifacts_archive: nil, pages_deployment: nil)
+ ensure_pages_metadatum.update!(deployed: false)
end
def update_pages_deployment!(deployment)
@@ -2521,7 +2518,18 @@ class Project < ApplicationRecord
end
def access_request_approvers_to_be_notified
- members.maintainers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT)
+ # For a personal project:
+ # The creator is added as a member with `Owner` access level, starting from GitLab 14.8
+ # The creator was added as a member with `Maintainer` access level, before GitLab 14.8
+ # So, to make sure access requests for all personal projects work as expected,
+ # we need to filter members with the scope `owners_and_maintainers`.
+ access_request_approvers = if personal?
+ members.owners_and_maintainers
+ else
+ members.maintainers
+ end
+
+ access_request_approvers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT)
end
def pages_lookup_path(trim_prefix: nil, domain: nil)
@@ -2817,6 +2825,10 @@ class Project < ApplicationRecord
end
end
+ def pending_delete_or_hidden?
+ pending_delete? || hidden?
+ end
+
private
# overridden in EE
@@ -2838,7 +2850,9 @@ class Project < ApplicationRecord
if @topic_list != self.topic_list
self.topics.delete_all
- self.topics = @topic_list.map { |topic| Projects::Topic.find_or_create_by(name: topic) }
+ self.topics = @topic_list.map do |topic|
+ Projects::Topic.where('lower(name) = ?', topic.downcase).order(total_projects_count: :desc).first_or_create(name: topic)
+ end
end
@topic_list = nil
@@ -3010,16 +3024,15 @@ class Project < ApplicationRecord
end
def ensure_project_namespace_in_sync
- # create project_namespace when project is created if create_project_namespace_on_project_create FF is enabled
+ # create project_namespace when project is created
build_project_namespace if project_namespace_creation_enabled?
- # regardless of create_project_namespace_on_project_create FF we need
- # to keep project and project namespace in sync if there is one
+ # we need to keep project and project namespace in sync if there is one
sync_attributes(project_namespace) if sync_project_namespace?
end
def project_namespace_creation_enabled?
- new_record? && !project_namespace && self.namespace && self.root_namespace.project_namespace_creation_enabled?
+ new_record? && !project_namespace && self.namespace
end
def sync_project_namespace?
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index c76332b21cd..5c6fdec16ca 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -9,7 +9,7 @@ class ProjectAuthorization < ApplicationRecord
validates :project, presence: true
validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
- validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true
+ validates :user, uniqueness: { scope: :project }, presence: true
def self.select_from_union(relations)
from_union(relations)
diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb
index d374ee120d1..3b514d5c5ff 100644
--- a/app/models/project_import_data.rb
+++ b/app/models/project_import_data.rb
@@ -14,7 +14,12 @@ class ProjectImportData < ApplicationRecord
insecure_mode: true,
algorithm: 'aes-256-cbc'
- serialize :data, JSON # rubocop:disable Cop/ActiveRecordSerialize
+ # NOTE
+ # We are serializing a project as `data` in an "unsafe" way here
+ # because the credentials are necessary for a successful import.
+ # This is safe because the serialization is only going between rails
+ # and the database, never to any end users.
+ serialize :data, Serializers::UnsafeJson # rubocop:disable Cop/ActiveRecordSerialize
validates :project, presence: true
diff --git a/app/models/project_pages_metadatum.rb b/app/models/project_pages_metadatum.rb
index 58dbac9057f..dc1e9319340 100644
--- a/app/models/project_pages_metadatum.rb
+++ b/app/models/project_pages_metadatum.rb
@@ -4,11 +4,13 @@ class ProjectPagesMetadatum < ApplicationRecord
extend SuppressCompositePrimaryKeyWarning
include EachBatch
+ include IgnorableColumns
self.primary_key = :project_id
+ ignore_columns :artifacts_archive_id, remove_with: '15.0', remove_after: '2022-04-22'
+
belongs_to :project, inverse_of: :pages_metadatum
- belongs_to :artifacts_archive, class_name: 'Ci::JobArtifact'
belongs_to :pages_deployment
scope :deployed, -> { where(deployed: true) }
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index c3c7508df9f..4b89d95c1a3 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -23,6 +23,10 @@ class ProjectTeam
add_user(user, :maintainer, current_user: current_user)
end
+ def add_owner(user, current_user: nil)
+ add_user(user, :owner, current_user: current_user)
+ end
+
def add_role(user, role, current_user: nil)
public_send(:"add_#{role}", user, current_user: current_user) # rubocop:disable GitlabSecurity/PublicSend
end
@@ -103,7 +107,9 @@ class ProjectTeam
if group
group.owners
else
- [project.owner]
+ # workaround until we migrate Project#owners to have membership with
+ # OWNER access level
+ Array.wrap(fetch_members(Gitlab::Access::OWNER)) | Array.wrap(project.owner)
end
end
@@ -173,7 +179,9 @@ class ProjectTeam
#
# Returns a Hash mapping user ID -> maximum access level.
def max_member_access_for_user_ids(user_ids)
- project.max_member_access_for_resource_ids(User, user_ids) do |user_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: project.max_member_access_for_resource_key(User),
+ resource_ids: user_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |user_ids|
project.project_authorizations
.where(user: user_ids)
.group(:user_id)
@@ -190,31 +198,15 @@ class ProjectTeam
end
def contribution_check_for_user_ids(user_ids)
- user_ids = user_ids.uniq
- key = "contribution_check_for_users:#{project.id}"
-
- Gitlab::SafeRequestStore[key] ||= {}
- contributors = Gitlab::SafeRequestStore[key] || {}
-
- user_ids -= contributors.keys
-
- return contributors if user_ids.empty?
-
- resource_contributors = project.merge_requests
- .merged
- .where(author_id: user_ids, target_branch: project.default_branch.to_s)
- .pluck(:author_id)
- .product([true]).to_h
-
- contributors.merge!(resource_contributors)
-
- missing_resource_ids = user_ids - resource_contributors.keys
-
- missing_resource_ids.each do |resource_id|
- contributors[resource_id] = false
+ Gitlab::SafeRequestLoader.execute(resource_key: "contribution_check_for_users:#{project.id}",
+ resource_ids: user_ids,
+ default_value: false) do |user_ids|
+ project.merge_requests
+ .merged
+ .where(author_id: user_ids, target_branch: project.default_branch.to_s)
+ .pluck(:author_id)
+ .product([true]).to_h
end
-
- contributors
end
def contributor?(user_id)
diff --git a/app/models/projects/build_artifacts_size_refresh.rb b/app/models/projects/build_artifacts_size_refresh.rb
new file mode 100644
index 00000000000..afb67b79f0d
--- /dev/null
+++ b/app/models/projects/build_artifacts_size_refresh.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module Projects
+ class BuildArtifactsSizeRefresh < ApplicationRecord
+ include BulkInsertSafe
+
+ STALE_WINDOW = 3.days
+
+ self.table_name = 'project_build_artifacts_size_refreshes'
+
+ belongs_to :project
+
+ validates :project, presence: true
+
+ STATES = {
+ created: 1,
+ running: 2,
+ pending: 3
+ }.freeze
+
+ state_machine :state, initial: :created do
+ # created -> running <-> pending
+ state :created, value: STATES[:created]
+ state :running, value: STATES[:running]
+ state :pending, value: STATES[:pending]
+
+ event :process do
+ transition [:created, :pending, :running] => :running
+ end
+
+ event :requeue do
+ transition running: :pending
+ end
+
+ # set it only the first time we execute the refresh
+ before_transition created: :running do |refresh|
+ refresh.reset_project_statistics!
+ refresh.refresh_started_at = Time.zone.now
+ end
+
+ before_transition running: any do |refresh, transition|
+ refresh.updated_at = Time.zone.now
+ end
+
+ before_transition running: :pending do |refresh, transition|
+ refresh.last_job_artifact_id = transition.args.first
+ end
+ end
+
+ scope :stale, -> { with_state(:running).where('updated_at < ?', STALE_WINDOW.ago) }
+ scope :remaining, -> { with_state(:created, :pending).or(stale) }
+
+ def self.enqueue_refresh(projects)
+ now = Time.zone.now
+
+ records = Array(projects).map do |project|
+ new(project: project, state: STATES[:created], created_at: now, updated_at: now)
+ end
+
+ bulk_insert!(records, skip_duplicates: true)
+ end
+
+ def self.process_next_refresh!
+ next_refresh = nil
+
+ transaction do
+ next_refresh = remaining
+ .order(:state, :updated_at)
+ .lock('FOR UPDATE SKIP LOCKED')
+ .take
+
+ next_refresh&.process!
+ end
+
+ next_refresh
+ end
+
+ def reset_project_statistics!
+ statistics = project.statistics
+ statistics.update!(build_artifacts_size: 0)
+ statistics.clear_counter!(:build_artifacts_size)
+ end
+
+ def next_batch(limit:)
+ project.job_artifacts.select(:id, :size)
+ .where('created_at <= ? AND id > ?', refresh_started_at, last_job_artifact_id.to_i)
+ .order(:created_at)
+ .limit(limit)
+ end
+ end
+end
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index 78bc2df2e1e..b42b03f0618 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -7,18 +7,19 @@ module Projects
include Avatarable
include Gitlab::SQL::Pattern
- validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
+ validates :name, presence: true, length: { maximum: 255 }
+ validates :name, uniqueness: { case_sensitive: false }, if: :name_changed?
validates :description, length: { maximum: 1024 }
has_many :project_topics, class_name: 'Projects::ProjectTopic'
has_many :projects, through: :project_topics
- scope :order_by_total_projects_count, -> { order(total_projects_count: :desc).order(id: :asc) }
+ scope :order_by_non_private_projects_count, -> { order(non_private_projects_count: :desc).order(id: :asc) }
scope :reorder_by_similarity, -> (search) do
order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [
{ column: arel_table['name'] }
])
- reorder(order_expression.desc, arel_table['total_projects_count'].desc, arel_table['id'])
+ reorder(order_expression.desc, arel_table['non_private_projects_count'].desc, arel_table['id'])
end
class << self
diff --git a/app/models/projects/triggered_hooks.rb b/app/models/projects/triggered_hooks.rb
new file mode 100644
index 00000000000..e3aa3d106b7
--- /dev/null
+++ b/app/models/projects/triggered_hooks.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Projects
+ class TriggeredHooks
+ def initialize(scope, data)
+ @scope = scope
+ @data = data
+ @relations = []
+ end
+
+ def add_hooks(relation)
+ @relations << relation
+ self
+ end
+
+ def execute
+ # Assumes that the relations implement TriggerableHooks
+ @relations.each do |hooks|
+ hooks.hooks_for(@scope).select_active(@scope, @data).each do |hook|
+ hook.async_execute(@data, @scope.to_s)
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/release.rb b/app/models/release.rb
index 0fda6940249..c6c0920c4d0 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -5,6 +5,8 @@ class Release < ApplicationRecord
include CacheMarkdownField
include Importable
include Gitlab::Utils::StrongMemoize
+ include EachBatch
+ include FromUnion
cache_markdown_field :description
@@ -24,6 +26,8 @@ class Release < ApplicationRecord
before_create :set_released_at
validates :project, :tag, presence: true
+ validates :tag, uniqueness: { scope: :project_id }
+
validates :description, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }, if: :description_changed?
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
validates :links, nested_attributes_duplicates: { scope: :release, child_attributes: %i[name url filepath] }
diff --git a/app/models/repository.rb b/app/models/repository.rb
index be8e530c650..346478b6689 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -15,6 +15,7 @@ class Repository
heads
tags
replace
+ #{REF_MERGE_REQUEST}
#{REF_ENVIRONMENTS}
#{REF_KEEP_AROUND}
#{REF_PIPELINES}
@@ -1084,10 +1085,10 @@ class Repository
blob.data
end
- def create_if_not_exists
+ def create_if_not_exists(default_branch = nil)
return if exists?
- raw.create_repository
+ raw.create_repository(default_branch)
after_create
true
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index b04fca64c87..38aaeff5c9a 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -350,24 +350,10 @@ class Snippet < ApplicationRecord
snippet_repository&.shard_name || Repository.pick_storage_shard
end
- # Repositories are created with a default branch. This branch
- # can be different from the default branch set in the platform.
- # This method changes the `HEAD` file to point to the existing
- # default branch in case it's different.
- def change_head_to_default_branch
- return unless repository.exists?
- # All snippets must have at least 1 file. Therefore, if
- # `HEAD` is empty is because it's pointing to the wrong
- # default branch
- return unless repository.empty? || list_files('HEAD').empty?
-
- repository.raw_repository.write_ref('HEAD', "refs/heads/#{default_branch}")
- end
-
def create_repository
return if repository_exists? && snippet_repository
- repository.create_if_not_exists
+ repository.create_if_not_exists(default_branch)
track_snippet_repository(repository.storage)
end
diff --git a/app/models/storage/hashed.rb b/app/models/storage/hashed.rb
index c61cd3b6b30..05e93f00912 100644
--- a/app/models/storage/hashed.rb
+++ b/app/models/storage/hashed.rb
@@ -3,6 +3,7 @@
module Storage
class Hashed
attr_accessor :container
+
delegate :gitlab_shell, :repository_storage, to: :container
REPOSITORY_PATH_PREFIX = '@hashed'
diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb
index 092e5249a3e..0d12a629b8e 100644
--- a/app/models/storage/legacy_project.rb
+++ b/app/models/storage/legacy_project.rb
@@ -3,6 +3,7 @@
module Storage
class LegacyProject
attr_accessor :project
+
delegate :namespace, :gitlab_shell, :repository_storage, to: :project
def initialize(project)
diff --git a/app/models/todo.rb b/app/models/todo.rb
index dc436570f52..eb5d9965955 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -34,6 +34,8 @@ class Todo < ApplicationRecord
ATTENTION_REQUESTED => :attention_requested
}.freeze
+ ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED].freeze
+
belongs_to :author, class_name: "User"
belongs_to :note
belongs_to :project
diff --git a/app/models/user.rb b/app/models/user.rb
index 9cd238904ff..b3bdc2c1c42 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -16,7 +16,7 @@ class User < ApplicationRecord
include FeatureGate
include CreatedAtFilterable
include BulkMemberAccessLoad
- include BlocksJsonSerialization
+ include BlocksUnsafeSerialization
include WithUploads
include OptionallySearch
include FromUnion
@@ -135,6 +135,7 @@ class User < ApplicationRecord
has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :webauthn_registrations
has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :saved_replies, class_name: '::Users::SavedReply'
has_one :user_synced_attributes_metadata, autosave: true
has_one :aws_role, class_name: 'Aws::Role'
@@ -276,24 +277,22 @@ class User < ApplicationRecord
after_update :username_changed_hook, if: :saved_change_to_username?
after_destroy :post_destroy_hook
after_destroy :remove_key_cache
- after_create :add_primary_email_to_emails!, if: :confirmed?
- after_commit(on: :update) do
- if previous_changes.key?('email')
- # Add the old primary email to Emails if not added already - this should be removed
- # after the background migration for MR https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70872/ has completed,
- # as the primary email is now added to Emails upon confirmation
- # Issue to remove that: https://gitlab.com/gitlab-org/gitlab/-/issues/344134
- previous_confirmed_at = previous_changes.key?('confirmed_at') ? previous_changes['confirmed_at'][0] : confirmed_at
- previous_email = previous_changes[:email][0]
- if previous_confirmed_at && !emails.exists?(email: previous_email)
- # rubocop: disable CodeReuse/ServiceClass
- Emails::CreateService.new(self, user: self, email: previous_email).execute(confirmed_at: previous_confirmed_at)
- # rubocop: enable CodeReuse/ServiceClass
- end
+ after_save if: -> { saved_change_to_email? && confirmed? } do
+ email_to_confirm = self.emails.find_by(email: self.email)
- update_invalid_gpg_signatures
+ if email_to_confirm.present?
+ if skip_confirmation_period_expiry_check
+ email_to_confirm.force_confirm
+ else
+ email_to_confirm.confirm
+ end
+ else
+ add_primary_email_to_emails!
end
end
+ after_commit(on: :update) do
+ update_invalid_gpg_signatures if previous_changes.key?('email')
+ end
after_initialize :set_projects_limit
@@ -1692,6 +1691,12 @@ class User < ApplicationRecord
end
end
+ def attention_requested_open_merge_requests_count(force: false)
+ Rails.cache.fetch(attention_request_cache_key, force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
+ MergeRequestsFinder.new(self, attention: self.username, state: 'opened', non_archived: true).execute.count
+ end
+ end
+
def assigned_open_issues_count(force: false)
Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count
@@ -1735,6 +1740,11 @@ class User < ApplicationRecord
def invalidate_merge_request_cache_counts
Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count'])
Rails.cache.delete(['users', id, 'review_requested_open_merge_requests_count'])
+ invalidate_attention_requested_count
+ end
+
+ def invalidate_attention_requested_count
+ Rails.cache.delete(attention_request_cache_key)
end
def invalidate_todos_cache_counts
@@ -1746,6 +1756,10 @@ class User < ApplicationRecord
Rails.cache.delete(['users', id, 'personal_projects_count'])
end
+ def attention_request_cache_key
+ ['users', id, 'attention_requested_open_merge_requests_count']
+ end
+
# This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth
# flow means we don't call that automatically (and can't conveniently do so).
#
@@ -1846,7 +1860,9 @@ class User < ApplicationRecord
#
# Returns a Hash mapping project ID -> maximum access level.
def max_member_access_for_project_ids(project_ids)
- max_member_access_for_resource_ids(Project, project_ids) do |project_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(Project),
+ resource_ids: project_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |project_ids|
project_authorizations.where(project: project_ids)
.group(:project_id)
.maximum(:access_level)
@@ -1861,7 +1877,9 @@ class User < ApplicationRecord
#
# Returns a Hash mapping project ID -> maximum access level.
def max_member_access_for_group_ids(group_ids)
- max_member_access_for_resource_ids(Group, group_ids) do |group_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(Group),
+ resource_ids: group_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |group_ids|
group_members.where(source: group_ids).group(:source_id).maximum(:access_level)
end
end
@@ -1993,29 +2011,6 @@ class User < ApplicationRecord
ci_job_token_scope.present?
end
- # override from Devise::Models::Confirmable
- #
- # Add the primary email to user.emails (or confirm it if it was already
- # present) when the primary email is confirmed.
- def confirm(args = {})
- saved = super(args)
- return false unless saved
-
- email_to_confirm = self.emails.find_by(email: self.email)
-
- if email_to_confirm.present?
- if skip_confirmation_period_expiry_check
- email_to_confirm.force_confirm(args)
- else
- email_to_confirm.confirm(args)
- end
- else
- add_primary_email_to_emails!
- end
-
- saved
- end
-
def user_project
strong_memoize(:user_project) do
personal_projects.find_by(path: username, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
@@ -2166,7 +2161,7 @@ class User < ApplicationRecord
end
def signup_email_invalid_message
- self.new_record? ? _('is not allowed for sign-up.') : _('is not allowed.')
+ self.new_record? ? _('is not allowed for sign-up. Please use your regular email address.') : _('is not allowed. Please use your regular email address.')
end
def check_username_format
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 5c39e29a128..0922323e12b 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -42,7 +42,13 @@ module Users
security_newsletter_callout: 39,
verification_reminder: 40, # EE-only
ci_deprecation_warning_for_types_keyword: 41,
- security_training_feature_promotion: 42 # EE-only
+ security_training_feature_promotion: 42, # EE-only
+ storage_enforcement_banner_first_enforcement_threshold: 43,
+ storage_enforcement_banner_second_enforcement_threshold: 44,
+ storage_enforcement_banner_third_enforcement_threshold: 45,
+ storage_enforcement_banner_fourth_enforcement_threshold: 46,
+ attention_requests_top_nav: 47,
+ attention_requests_side_nav: 48
}
validates :feature_name,
diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb
index 556ee03605d..998a5deb0fd 100644
--- a/app/models/users/credit_card_validation.rb
+++ b/app/models/users/credit_card_validation.rb
@@ -8,7 +8,7 @@ module Users
belongs_to :user
- validates :holder_name, length: { maximum: 26 }
+ validates :holder_name, length: { maximum: 50 }
validates :network, length: { maximum: 32 }
validates :last_digits, allow_nil: true, numericality: {
greater_than_or_equal_to: 0, less_than_or_equal_to: 9999
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index 0dc449719ab..839be8d2a48 100644
--- a/app/models/users/group_callout.rb
+++ b/app/models/users/group_callout.rb
@@ -11,10 +11,10 @@ module Users
enum feature_name: {
invite_members_banner: 1,
approaching_seat_count_threshold: 2, # EE-only
- storage_enforcement_banner_first_enforcement_threshold: 43,
- storage_enforcement_banner_second_enforcement_threshold: 44,
- storage_enforcement_banner_third_enforcement_threshold: 45,
- storage_enforcement_banner_fourth_enforcement_threshold: 46
+ storage_enforcement_banner_first_enforcement_threshold: 3,
+ storage_enforcement_banner_second_enforcement_threshold: 4,
+ storage_enforcement_banner_third_enforcement_threshold: 5,
+ storage_enforcement_banner_fourth_enforcement_threshold: 6
}
validates :group, presence: true
diff --git a/app/models/users/saved_reply.rb b/app/models/users/saved_reply.rb
new file mode 100644
index 00000000000..7737d826b05
--- /dev/null
+++ b/app/models/users/saved_reply.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Users
+ class SavedReply < ApplicationRecord
+ self.table_name = 'saved_replies'
+
+ belongs_to :user
+
+ validates :user_id, :name, :content, presence: true
+ validates :name,
+ length: { maximum: 255 },
+ uniqueness: { scope: [:user_id] },
+ format: {
+ with: Gitlab::Regex.saved_reply_name_regex,
+ message: Gitlab::Regex.saved_reply_name_regex_message
+ }
+ validates :content, length: { maximum: 10000 }
+ end
+end
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index e114e30d589..622070abd88 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -87,8 +87,7 @@ class Wiki
end
def create_wiki_repository
- repository.create_if_not_exists
- change_head_to_default_branch
+ repository.create_if_not_exists(default_branch)
raise CouldNotCreateWikiError unless repository_exists?
rescue StandardError => err
@@ -150,10 +149,10 @@ class Wiki
# the page.
#
# Returns an initialized WikiPage instance or nil
- def find_page(title, version = nil)
+ def find_page(title, version = nil, load_content: true)
page_title, page_dir = page_title_and_dir(title)
- if page = wiki.page(title: page_title, version: version, dir: page_dir)
+ if page = wiki.page(title: page_title, version: version, dir: page_dir, load_content: load_content)
WikiPage.new(self, page)
end
end
@@ -322,16 +321,6 @@ class Wiki
def default_message(action, title)
"#{user.username} #{action} page: #{title}"
end
-
- def change_head_to_default_branch
- # If the wiki has commits in the 'HEAD' branch means that the current
- # HEAD is pointing to the right branch. If not, it could mean that either
- # the repo has just been created or that 'HEAD' is pointing
- # to the wrong branch and we need to rewrite it
- return if repository.raw_repository.commit_count('HEAD') != 0
-
- repository.raw_repository.write_ref('HEAD', "refs/heads/#{default_branch}")
- end
end
Wiki.prepend_mod_with('Wiki')
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 3dbbbcdfe23..803b9781ac4 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -45,6 +45,7 @@ class WikiPage
# The GitLab Wiki instance.
attr_reader :wiki
+
delegate :container, to: :wiki
# The raw Gitlab::Git::WikiPage instance.
@@ -315,7 +316,6 @@ class WikiPage
end
def update_front_matter(attrs)
- return unless Gitlab::WikiPages::FrontMatterParser.enabled?(container)
return unless attrs.has_key?(:front_matter)
fm_yaml = serialize_front_matter(attrs[:front_matter])
@@ -326,7 +326,7 @@ class WikiPage
def parsed_content
strong_memoize(:parsed_content) do
- Gitlab::WikiPages::FrontMatterParser.new(raw_content, container).parse
+ Gitlab::WikiPages::FrontMatterParser.new(raw_content).parse
end
end
@@ -404,3 +404,5 @@ class WikiPage
})
end
end
+
+WikiPage.prepend_mod
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 99f05e4a181..557694da35a 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -7,4 +7,12 @@ class WorkItem < Issue
def noteable_target_type_name
'issue'
end
+
+ private
+
+ def record_create_action
+ super
+
+ Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter.track_work_item_created_action(author: author)
+ end
end
diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb
index 494c4f5abe4..080513b28e9 100644
--- a/app/models/work_items/type.rb
+++ b/app/models/work_items/type.rb
@@ -38,6 +38,7 @@ module WorkItems
scope :default, -> { where(namespace: nil) }
scope :order_by_name_asc, -> { order('LOWER(name)') }
+ scope :by_type, ->(base_type) { where(base_type: base_type) }
def self.default_by_type(type)
find_by(namespace_id: nil, base_type: type)
diff --git a/app/policies/alert_management/alert_policy.rb b/app/policies/alert_management/alert_policy.rb
index 85fafcde2cc..e2383921c82 100644
--- a/app/policies/alert_management/alert_policy.rb
+++ b/app/policies/alert_management/alert_policy.rb
@@ -5,3 +5,5 @@ module AlertManagement
delegate { @subject.project }
end
end
+
+AlertManagement::AlertPolicy.prepend_mod
diff --git a/app/policies/application_setting_policy.rb b/app/policies/application_setting_policy.rb
index 114c71fd99d..6d0b5f36fa4 100644
--- a/app/policies/application_setting_policy.rb
+++ b/app/policies/application_setting_policy.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
class ApplicationSettingPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass
- rule { admin }.enable :read_application_setting
+ rule { admin }.policy do
+ enable :read_application_setting
+ enable :update_runners_registration_token
+ end
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 77897c5807f..f8e7a912896 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -67,7 +67,7 @@ class BasePolicy < DeclarativePolicy::Base
rule { default }.enable :read_cross_project
- condition(:is_gitlab_com, score: 0, scope: :global) { ::Gitlab.dev_env_or_com? }
+ condition(:is_gitlab_com, score: 0, scope: :global) { ::Gitlab.com? }
end
BasePolicy.prepend_mod_with('BasePolicy')
diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb
index bdbe7021276..6dfe9cc496b 100644
--- a/app/policies/ci/runner_policy.rb
+++ b/app/policies/ci/runner_policy.rb
@@ -9,6 +9,10 @@ module Ci
@user.owns_runner?(@subject)
end
+ condition(:belongs_to_multiple_projects) do
+ @subject.belongs_to_more_than_one_project?
+ end
+
rule { anonymous }.prevent_all
rule { admin }.policy do
@@ -22,6 +26,8 @@ module Ci
enable :delete_runner
end
+ rule { ~admin & belongs_to_multiple_projects }.prevent :delete_runner
+
rule { ~admin & locked }.prevent :assign_runner
end
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 2a2ddf29899..fa7b117f3cd 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -115,7 +115,6 @@ class GlobalPolicy < BasePolicy
enable :approve_user
enable :reject_user
enable :read_usage_trends_measurement
- enable :update_runners_registration_token
end
# We can't use `read_statistics` because the user may have different permissions for different projects
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 76e5b3ece53..7a49ad3d4aa 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -80,9 +80,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
condition(:crm_enabled, score: 0, scope: :subject) { Feature.enabled?(:customer_relations, @subject) && @subject.crm_enabled? }
- with_scope :subject
- condition(:group_runner_registration_allowed, score: 0, scope: :subject) do
- Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group')
+ condition(:group_runner_registration_allowed) do
+ Feature.disabled?(:runner_registration_control, default_enabled: :yaml) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group')
end
rule { can?(:read_group) & design_management_enabled }.policy do
@@ -280,7 +279,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
prevent :admin_crm_organization
end
- rule { ~group_runner_registration_allowed }.policy do
+ rule { ~admin & ~group_runner_registration_allowed }.policy do
prevent :register_group_runners
end
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index c9c13b29643..a667c843bc6 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -13,7 +13,7 @@ class IssuePolicy < IssuablePolicy
end
desc "User can read contacts belonging to the issue group"
- condition(:can_read_crm_contacts, scope: :subject) { @user.can?(:read_crm_contact, @subject.project.group) }
+ condition(:can_read_crm_contacts, scope: :subject) { @user.can?(:read_crm_contact, @subject.project.root_ancestor) }
desc "Issue is confidential"
condition(:confidential, scope: :subject) { @subject.confidential? }
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 4cc5ed06d61..09085bef9f0 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -194,6 +194,10 @@ class ProjectPolicy < BasePolicy
condition(:"#{f}_disabled", score: 32) { !access_allowed_to?(f.to_sym) }
end
+ condition(:project_runner_registration_allowed) do
+ Feature.disabled?(:runner_registration_control, default_enabled: :yaml) || Gitlab::CurrentSettings.valid_runner_registrars.include?('project')
+ end
+
# `:read_project` may be prevented in EE, but `:read_project_for_iids` should
# not.
rule { guest | admin }.enable :read_project_for_iids
@@ -230,6 +234,8 @@ class ProjectPolicy < BasePolicy
enable :set_emails_disabled
enable :set_show_default_award_emojis
enable :set_warn_about_potentially_unwanted_characters
+
+ enable :register_project_runners
end
rule { can?(:guest_access) }.policy do
@@ -264,8 +270,6 @@ class ProjectPolicy < BasePolicy
enable :create_work_item
end
- rule { can?(:update_issue) }.enable :update_work_item
-
# These abilities are not allowed to admins that are not members of the project,
# that's why they are defined separately.
rule { guest & can?(:download_code) }.enable :build_download_code
@@ -409,6 +413,7 @@ class ProjectPolicy < BasePolicy
enable :admin_feature_flag
enable :admin_feature_flags_user_lists
enable :update_escalation_status
+ enable :read_secure_files
end
rule { can?(:developer_access) & user_confirmed? }.policy do
@@ -455,8 +460,10 @@ class ProjectPolicy < BasePolicy
enable :update_freeze_period
enable :destroy_freeze_period
enable :admin_feature_flags_client
+ enable :register_project_runners
enable :update_runners_registration_token
enable :admin_project_google_cloud
+ enable :admin_secure_files
end
rule { public_project & metrics_dashboard_allowed }.policy do
@@ -729,6 +736,10 @@ class ProjectPolicy < BasePolicy
enable :access_security_and_compliance
end
+ rule { ~admin & ~project_runner_registration_allowed }.policy do
+ prevent :register_project_runners
+ end
+
private
def user_is_user?
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 018c061af9f..de99cbffb6f 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -23,9 +23,12 @@ class UserPolicy < BasePolicy
enable :destroy_user
enable :update_user
enable :update_user_status
+ enable :create_saved_replies
+ enable :update_saved_replies
enable :read_user_personal_access_tokens
enable :read_group_count
enable :read_user_groups
+ enable :read_saved_replies
end
rule { default }.enable :read_user_profile
diff --git a/app/policies/users/saved_reply_policy.rb b/app/policies/users/saved_reply_policy.rb
new file mode 100644
index 00000000000..be76c526012
--- /dev/null
+++ b/app/policies/users/saved_reply_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Users
+ class SavedReplyPolicy < BasePolicy
+ delegate { @subject.user }
+ end
+end
diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb
index 7ba5102a406..b4723bc7ed8 100644
--- a/app/policies/work_item_policy.rb
+++ b/app/policies/work_item_policy.rb
@@ -1,12 +1,9 @@
# frozen_string_literal: true
-class WorkItemPolicy < BasePolicy
- delegate { @subject.project }
+class WorkItemPolicy < IssuePolicy
+ rule { can?(:owner_access) | is_author }.enable :delete_work_item
- desc 'User is author of the work item'
- condition(:author) do
- @user && @user == @subject.author
- end
+ rule { can?(:update_issue) }.enable :update_work_item
- rule { can?(:owner_access) | author }.enable :delete_work_item
+ rule { can?(:read_issue) }.enable :read_work_item
end
diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb
index b692935d229..659e991e9d8 100644
--- a/app/presenters/alert_management/alert_presenter.rb
+++ b/app/presenters/alert_management/alert_presenter.rb
@@ -3,7 +3,6 @@
module AlertManagement
class AlertPresenter < Gitlab::View::Presenter::Delegated
include IncidentManagement::Settings
- include ActionView::Helpers::UrlHelper
presents ::AlertManagement::Alert
delegator_override_with Gitlab::Utils::StrongMemoize # This module inclusion is expected. See https://gitlab.com/gitlab-org/gitlab/-/issues/352884.
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index 47b72df32a2..aeab914dc9e 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -1,5 +1,4 @@
# frozen_string_literal: true
-require 'ipynbdiff'
class BlobPresenter < Gitlab::View::Presenter::Delegated
include ApplicationHelper
@@ -56,13 +55,19 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end
def replace_path
- url_helpers.project_create_blob_path(project, ref_qualified_path)
+ url_helpers.project_update_blob_path(project, ref_qualified_path)
end
def pipeline_editor_path
project_ci_pipeline_editor_path(project, branch_name: blob.commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default
end
+ def gitpod_blob_url
+ return unless Gitlab::CurrentSettings.gitpod_enabled && !current_user.nil? && current_user.gitpod_enabled
+
+ "#{Gitlab::CurrentSettings.gitpod_url}##{url_helpers.project_tree_url(project, tree_join(blob.commit_id, blob.path || ''))}"
+ end
+
def find_file_path
url_helpers.project_find_file_path(project, ref_qualified_path)
end
@@ -104,6 +109,10 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
fork_path_for_current_user(project, ide_edit_path)
end
+ def fork_and_view_path
+ fork_path_for_current_user(project, web_path)
+ end
+
def can_modify_blob?
super(blob, project, blob.commit_id)
end
@@ -128,6 +137,14 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
external_storage_url_or_path(url_helpers.project_raw_url(project, ref_qualified_path), project)
end
+ def code_navigation_path
+ Gitlab::CodeNavigationPath.new(project, blob.commit_id).full_json_path_for(blob.path)
+ end
+
+ def project_blob_path_root
+ project_blob_path(project, blob.commit_id)
+ end
+
private
def url_helpers
diff --git a/app/presenters/blobs/notebook_presenter.rb b/app/presenters/blobs/notebook_presenter.rb
new file mode 100644
index 00000000000..16ae1e71191
--- /dev/null
+++ b/app/presenters/blobs/notebook_presenter.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Blobs
+ class NotebookPresenter < ::BlobPresenter
+ def gitattr_language
+ 'md'
+ end
+ end
+end
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 8e1b675d051..082993130a1 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -105,10 +105,7 @@ module Ci
end
def refspec_for_persistent_ref
- # Use persistent_ref.sha because it sometimes causes 'git fetch' to do
- # less work. See
- # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/746.
- "+#{pipeline.persistent_ref.sha}:#{pipeline.persistent_ref.path}"
+ "+#{pipeline.persistent_ref.path}:#{pipeline.persistent_ref.path}"
end
def persistent_ref_exist?
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 2818e6da036..410b633df50 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -5,7 +5,6 @@ module Ci
include Gitlab::Utils::StrongMemoize
delegator_override_with Gitlab::Utils::StrongMemoize # This module inclusion is expected. See https://gitlab.com/gitlab-org/gitlab/-/issues/352884.
- delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::UrlHelper` inclusion as it overrides `Ci::Pipeline#tag`
# We use a class method here instead of a constant, allowing EE to redefine
# the returned `Hash` more easily.
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index cc466e0ff81..82152ce42ae 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -32,6 +32,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
new_polymorphic_path([clusterable, :cluster], options)
end
+ def connect_path
+ polymorphic_path([clusterable, :clusters], action: :connect)
+ end
+
def authorize_aws_role_path
polymorphic_path([clusterable, :clusters], action: :authorize_aws_role)
end
diff --git a/app/presenters/environment_presenter.rb b/app/presenters/environment_presenter.rb
index 6c8da86187c..fe828fb9fd8 100644
--- a/app/presenters/environment_presenter.rb
+++ b/app/presenters/environment_presenter.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class EnvironmentPresenter < Gitlab::View::Presenter::Delegated
- include ActionView::Helpers::UrlHelper
-
presents ::Environment, as: :environment
def path
diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb
index e9340a42e51..5dd2f3adda5 100644
--- a/app/presenters/gitlab/blame_presenter.rb
+++ b/app/presenters/gitlab/blame_presenter.rb
@@ -2,7 +2,6 @@
module Gitlab
class BlamePresenter < Gitlab::View::Presenter::Simple
- include ActionView::Helpers::UrlHelper
include ActionView::Helpers::TranslationHelper
include ActionView::Context
include AvatarsHelper
@@ -75,5 +74,13 @@ module Gitlab
def project_duration
@project_duration ||= age_map_duration(groups, project)
end
+
+ def link_to(*args, &block)
+ ActionController::Base.helpers.link_to(*args, &block)
+ end
+
+ def mail_to(*args, &block)
+ ActionController::Base.helpers.mail_to(*args, &block)
+ end
end
end
diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb
index f2550eb17e3..9e4a3b403ea 100644
--- a/app/presenters/instance_clusterable_presenter.rb
+++ b/app/presenters/instance_clusterable_presenter.rb
@@ -38,6 +38,11 @@ class InstanceClusterablePresenter < ClusterablePresenter
admin_cluster_path(cluster, params)
end
+ override :connect_path
+ def connect_path
+ connect_admin_clusters_path
+ end
+
override :create_user_clusters_path
def create_user_clusters_path
create_user_admin_clusters_path
diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb
index 8d604f9a0f6..6929bf79fdf 100644
--- a/app/presenters/label_presenter.rb
+++ b/app/presenters/label_presenter.rb
@@ -14,6 +14,10 @@ class LabelPresenter < Gitlab::View::Presenter::Delegated
end
end
+ def text_color_class
+ "gl-label-text-#{label.color.contrast.luminosity}"
+ end
+
def destroy_path
case label
when GroupLabel then group_label_path(label.group, label)
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 8450679dd79..6dd3908b21d 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
- include ActionView::Helpers::UrlHelper
include GitlabRoutingHelper
include MarkupHelper
include TreeHelper
@@ -150,7 +149,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
)
end
- def assign_to_closing_issues_link
+ def assign_to_closing_issues_path
+ assign_related_issues_project_merge_request_path(project, merge_request)
+ end
+
+ def assign_to_closing_issues_count
# rubocop: disable CodeReuse/ServiceClass
issues = MergeRequests::AssignIssuesService.new(project: project,
current_user: current_user,
@@ -158,14 +161,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
merge_request: merge_request,
closes_issues: closing_issues
}).assignable_issues
- path = assign_related_issues_project_merge_request_path(project, merge_request)
- if issues.present?
- if issues.count > 1
- link_to _('Assign yourself to these issues'), path, method: :post
- else
- link_to _('Assign yourself to this issue'), path, method: :post
- end
- end
+ issues.count
# rubocop: enable CodeReuse/ServiceClass
end
@@ -290,6 +286,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def user_can_fork_project?
can?(current_user, :fork_project, project)
end
+
+ # Avoid including ActionView::Helpers::UrlHelper
+ def link_to(*args)
+ ApplicationController.helpers.link_to(*args)
+ end
end
MergeRequestPresenter.prepend_mod_with('MergeRequestPresenter')
diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb
index 6c4d1143c0f..624fa1e0cb0 100644
--- a/app/presenters/project_clusterable_presenter.rb
+++ b/app/presenters/project_clusterable_presenter.rb
@@ -22,12 +22,12 @@ class ProjectClusterablePresenter < ClusterablePresenter
override :sidebar_text
def sidebar_text
- s_('ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way.')
+ s_('ClusterIntegration|Use GitLab to deploy to your cluster, run jobs, use review apps, and more.')
end
override :learn_more_link
def learn_more_link
- ApplicationController.helpers.link_to(s_('ClusterIntegration|Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ ApplicationController.helpers.link_to(s_('ClusterIntegration|Learn more about Kubernetes.'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
end
def metrics_dashboard_path(cluster)
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 9e64d2d43a2..098519cdffe 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -2,7 +2,6 @@
class ProjectPresenter < Gitlab::View::Presenter::Delegated
include ActionView::Helpers::NumberHelper
- include ActionView::Helpers::UrlHelper
include GitlabRoutingHelper
include StorageHelper
include TreeHelper
@@ -138,17 +137,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
ide_edit_path(project, default_branch_or_main, 'README.md')
end
- def add_code_quality_ci_yml_path
- add_special_file_path(
- file_name: ci_config_path_or_default,
- commit_message: s_("CommitMessage|Add %{file_name} and create a code quality job") % { file_name: ci_config_path_or_default },
- additional_params: {
- template: 'Code-Quality',
- code_quality_walkthrough: true
- }
- )
- end
-
def license_short_name
license = repository.license
license&.nickname || license&.name || 'LICENSE'
@@ -473,6 +461,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
project.topics.map(&:name)
end
end
+
+ # Avoid including ActionView::Helpers::UrlHelper
+ def content_tag(*args)
+ ActionController::Base.helpers.content_tag(*args)
+ end
end
ProjectPresenter.prepend_mod_with('ProjectPresenter')
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index dac42af38bf..fc47ece6199 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -1,14 +1,8 @@
# frozen_string_literal: true
class ReleasePresenter < Gitlab::View::Presenter::Delegated
- include ActionView::Helpers::UrlHelper
-
presents ::Release, as: :release
- # TODO: Remove `delegate` as it's redundant due to SimpleDelegator.
- delegator_override :tag, :project
- delegate :project, :tag, to: :release
-
def commit_path
return unless release.commit && can_download_code?
diff --git a/app/presenters/releases/evidence_presenter.rb b/app/presenters/releases/evidence_presenter.rb
index bdc053a303b..f7da6ceb8fe 100644
--- a/app/presenters/releases/evidence_presenter.rb
+++ b/app/presenters/releases/evidence_presenter.rb
@@ -2,8 +2,6 @@
module Releases
class EvidencePresenter < Gitlab::View::Presenter::Delegated
- include ActionView::Helpers::UrlHelper
-
presents ::Releases::Evidence, as: :evidence
def filepath
diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb
index 72f967b8beb..4755b88cbea 100644
--- a/app/presenters/search_service_presenter.rb
+++ b/app/presenters/search_service_presenter.rb
@@ -25,7 +25,7 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated
case scope
when 'users'
- objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord
+ objects.eager_load(:status) if objects.respond_to?(:eager_load) # rubocop:disable CodeReuse/ActiveRecord
when 'commits'
prepare_commits_for_rendering(objects)
else
diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb
index 5a99f10b6e7..dc775fb4160 100644
--- a/app/presenters/user_presenter.rb
+++ b/app/presenters/user_presenter.rb
@@ -11,6 +11,22 @@ class UserPresenter < Gitlab::View::Presenter::Delegated
should_be_private? ? ProjectMember.none : user.project_members
end
+ def preferences_gitpod_path
+ profile_preferences_path(anchor: 'user_gitpod_enabled') if application_gitpod_enabled?
+ end
+
+ def profile_enable_gitpod_path
+ profile_path(user: { gitpod_enabled: true }) if application_gitpod_enabled?
+ end
+
+ delegator_override :saved_replies
+ def saved_replies
+ return ::Users::SavedReply.none unless Feature.enabled?(:saved_replies, current_user, default_enabled: :yaml)
+ return ::Users::SavedReply.none unless current_user.can?(:read_saved_replies, user)
+
+ user.saved_replies
+ end
+
private
def can?(*args)
@@ -20,4 +36,8 @@ class UserPresenter < Gitlab::View::Presenter::Delegated
def should_be_private?
!Ability.allowed?(current_user, :read_user_profile, user)
end
+
+ def application_gitpod_enabled?
+ Gitlab::CurrentSettings.gitpod_enabled
+ end
end
diff --git a/app/serializers/analytics/cycle_analytics/stage_entity.rb b/app/serializers/analytics/cycle_analytics/stage_entity.rb
index cfbf6f60e38..c1d415dfb40 100644
--- a/app/serializers/analytics/cycle_analytics/stage_entity.rb
+++ b/app/serializers/analytics/cycle_analytics/stage_entity.rb
@@ -57,7 +57,8 @@ module Analytics
def html_description(event)
options = {}
if event.label_based?
- options[:label_html] = render_label(event.label, link: '', small: true, tooltip: true)
+ label = event.label.present(issuable_subject: event.label.subject)
+ options[:label_html] = render_label(label, link: '', small: true, tooltip: true)
end
content_tag(:p) { event.html_description(options).html_safe }
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
index ba42e14be22..e2d24e74b29 100644
--- a/app/serializers/cluster_entity.rb
+++ b/app/serializers/cluster_entity.rb
@@ -24,7 +24,7 @@ class ClusterEntity < Grape::Entity
end
expose :kubernetes_errors do |cluster|
- ClusterErrorEntity.new(cluster)
+ Clusters::KubernetesErrorEntity.new(cluster)
end
expose :enable_advanced_logs_querying do |cluster|
diff --git a/app/serializers/cluster_error_entity.rb b/app/serializers/cluster_error_entity.rb
deleted file mode 100644
index c749537cb94..00000000000
--- a/app/serializers/cluster_error_entity.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-class ClusterErrorEntity < Grape::Entity
- expose :connection_error
- expose :metrics_connection_error
- expose :node_connection_error
-end
diff --git a/app/serializers/clusters/kubernetes_error_entity.rb b/app/serializers/clusters/kubernetes_error_entity.rb
new file mode 100644
index 00000000000..ceab10f232e
--- /dev/null
+++ b/app/serializers/clusters/kubernetes_error_entity.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Clusters
+ class KubernetesErrorEntity < Grape::Entity
+ expose :connection_error
+ expose :metrics_connection_error
+ expose :node_connection_error
+ end
+end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index 7a2fba73f3a..020c66af777 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -73,3 +73,5 @@ class DeploymentEntity < Grape::Entity
request.try(:project) || options[:project]
end
end
+
+DeploymentEntity.prepend_mod
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index e0565a1e506..c818fcd6215 100644
--- a/app/serializers/diffs_entity.rb
+++ b/app/serializers/diffs_entity.rb
@@ -23,7 +23,7 @@ class DiffsEntity < Grape::Entity
CommitEntity.represent(options[:commit], commit_options(options))
end
- expose :context_commits, using: API::Entities::Commit, if: -> (diffs, options) { merge_request&.project&.context_commits_enabled? } do |diffs|
+ expose :context_commits, using: API::Entities::Commit do |diffs|
options[:context_commits]
end
@@ -89,7 +89,7 @@ class DiffsEntity < Grape::Entity
project_blob_path(merge_request.project, merge_request.diff_head_sha)
end
- expose :context_commits_diff, if: -> (_) { merge_request&.project&.context_commits_enabled? } do |diffs, options|
+ expose :context_commits_diff do |diffs, options|
next unless merge_request.context_commits_diff.commits_count > 0
ContextCommitsDiffEntity.represent(
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 6105b52fbda..d484f60ed8f 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -20,6 +20,7 @@ class EnvironmentEntity < Grape::Entity
expose :last_deployment, using: DeploymentEntity
expose :stop_action_available?, as: :has_stop_action
expose :rollout_status, if: -> (*) { can_read_deploy_board? }, using: RolloutStatusEntity
+ expose :tier
expose :upcoming_deployment, if: -> (environment) { environment.upcoming_deployment } do |environment, ops|
DeploymentEntity.represent(environment.upcoming_deployment,
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 8d9b73b2290..a8645c8670d 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -60,7 +60,7 @@ class EnvironmentSerializer < BaseSerializer
Preloaders::Environments::DeploymentPreloader.new(resource)
.execute_with_union(:upcoming_deployment, deployment_associations)
- resource.all.to_a.tap do |environments|
+ resource.to_a.tap do |environments|
environments.each do |environment|
# Batch loading the commits of the deployments
environment.last_deployment&.commit&.try(:lazy_author)
diff --git a/app/serializers/fork_namespace_entity.rb b/app/serializers/fork_namespace_entity.rb
index 2be37d23a05..997abb0f148 100644
--- a/app/serializers/fork_namespace_entity.rb
+++ b/app/serializers/fork_namespace_entity.rb
@@ -30,14 +30,6 @@ class ForkNamespaceEntity < Grape::Entity
markdown_description(namespace)
end
- expose :can_create_project do |namespace, options|
- if Feature.enabled?(:fork_project_form, options[:project], default_enabled: :yaml)
- true
- else
- options[:current_user].can?(:create_projects, namespace)
- end
- end
-
private
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/serializers/issue_sidebar_basic_entity.rb b/app/serializers/issue_sidebar_basic_entity.rb
index 9c6601afd5e..7222b5df425 100644
--- a/app/serializers/issue_sidebar_basic_entity.rb
+++ b/app/serializers/issue_sidebar_basic_entity.rb
@@ -10,6 +10,11 @@ class IssueSidebarBasicEntity < IssuableSidebarBasicEntity
can?(current_user, :update_escalation_status, issue.project)
end
end
+
+ expose :show_crm_contacts do |issuable|
+ current_user&.can?(:read_crm_contact, issuable.project.root_ancestor) &&
+ CustomerRelations::Contact.exists_for_group?(issuable.project.root_ancestor)
+ end
end
IssueSidebarBasicEntity.prepend_mod_with('IssueSidebarBasicEntity')
diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb
index e586d7f8407..5785715390f 100644
--- a/app/serializers/label_entity.rb
+++ b/app/serializers/label_entity.rb
@@ -4,7 +4,9 @@ class LabelEntity < Grape::Entity
expose :id
expose :title
- expose :color
+ expose :color do |label|
+ label.color.to_s
+ end
expose :description
expose :group_id
expose :project_id
diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb
index f2f97f560e0..bfb5b3eeae6 100644
--- a/app/serializers/member_entity.rb
+++ b/app/serializers/member_entity.rb
@@ -41,7 +41,7 @@ class MemberEntity < Grape::Entity
expose :valid_level_roles, as: :valid_roles
expose :user, if: -> (member) { member.user.present? } do |member, options|
- MemberUserEntity.represent(member.user, source: options[:source])
+ MemberUserEntity.represent(member.user, options)
end
expose :state
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index b9c71e6d97b..21ab20747d0 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -104,7 +104,11 @@ class MergeRequestWidgetEntity < Grape::Entity
# include them if they are explicitly requested on first load.
expose :issues_links, if: -> (_, opts) { opts[:issues_links] } do
expose :assign_to_closing do |merge_request|
- presenter(merge_request).assign_to_closing_issues_link
+ presenter(merge_request).assign_to_closing_issues_path
+ end
+
+ expose :assign_to_closing_count do |merge_request|
+ presenter(merge_request).assign_to_closing_issues_count
end
expose :closing do |merge_request|
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
index f459e700c03..76797a773b5 100644
--- a/app/serializers/pipeline_details_entity.rb
+++ b/app/serializers/pipeline_details_entity.rb
@@ -10,11 +10,6 @@ class PipelineDetailsEntity < Ci::PipelineEntity
expose :details do
expose :manual_actions, using: BuildActionEntity
expose :scheduled_actions, using: BuildActionEntity
- expose :code_quality_build_path, if: -> (_, options) { options[:code_quality_walkthrough] } do |pipeline|
- next unless code_quality_build = pipeline.builds.finished.find_by_name('code_quality')
-
- project_job_path(pipeline.project, code_quality_build, code_quality_walkthrough: true)
- end
end
expose :triggered_by_pipeline, as: :triggered_by, with: TriggeredPipelineEntity
diff --git a/app/serializers/service_event_entity.rb b/app/serializers/service_event_entity.rb
index a1fbfa1d4c4..49a4944b2b0 100644
--- a/app/serializers/service_event_entity.rb
+++ b/app/serializers/service_event_entity.rb
@@ -4,7 +4,7 @@ class ServiceEventEntity < Grape::Entity
include RequestAwareEntity
expose :title do |event|
- event
+ IntegrationsHelper.integration_event_title(event)
end
expose :event_field_name, as: :name
diff --git a/app/serializers/service_field_entity.rb b/app/serializers/service_field_entity.rb
index aad9db5ffea..b13f2c0e217 100644
--- a/app/serializers/service_field_entity.rb
+++ b/app/serializers/service_field_entity.rb
@@ -4,7 +4,7 @@ class ServiceFieldEntity < Grape::Entity
include RequestAwareEntity
include Gitlab::Utils::StrongMemoize
- expose :type, :name, :placeholder, :required, :choices, :checkbox_label
+ expose :section, :type, :name, :placeholder, :required, :choices, :checkbox_label
expose :title do |field|
non_empty_password?(field) ? field[:non_empty_password_title] : field[:title]
diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb
index ab8d1176b9e..34c2003bd01 100644
--- a/app/services/alert_management/create_alert_issue_service.rb
+++ b/app/services/alert_management/create_alert_issue_service.rb
@@ -22,9 +22,7 @@ module AlertManagement
return result unless result.success?
issue = result.payload[:issue]
- update_title_for(issue)
-
- SystemNoteService.new_alert_issue(alert, issue, user)
+ perform_after_create_tasks(issue)
result
end
@@ -56,6 +54,12 @@ module AlertManagement
issue.update!(title: "#{DEFAULT_INCIDENT_TITLE} #{issue.iid}")
end
+ def perform_after_create_tasks(issue)
+ update_title_for(issue)
+
+ SystemNoteService.new_alert_issue(alert, issue, user)
+ end
+
def error(message, issue = nil)
ServiceResponse.error(payload: { issue: issue }, message: message)
end
@@ -75,3 +79,5 @@ module AlertManagement
end
end
end
+
+AlertManagement::CreateAlertIssueService.prepend_mod
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 84518fd6b0e..bb6a52eb2f4 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -59,12 +59,7 @@ module Auth
token.expire_time = token_expire_at
token[:access] = names.map do |name|
- {
- type: type,
- name: name,
- actions: actions,
- migration_eligible: type == 'repository' ? migration_eligible(repository_path: name) : nil
- }.compact
+ { type: type, name: name, actions: actions }
end
token.encoded
@@ -136,12 +131,7 @@ module Auth
#
ensure_container_repository!(path, authorized_actions)
- {
- type: type,
- name: path.to_s,
- actions: authorized_actions,
- migration_eligible: self.class.migration_eligible(project: requested_project)
- }.compact
+ { type: type, name: path.to_s, actions: authorized_actions }
end
def actively_importing?(actions, path)
@@ -153,28 +143,6 @@ module Auth
container_repository.migration_importing?
end
- 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
- # rollout, we'll add their top-level group/namespace to the `container_registry_migration_phase1_deny` FF. Later,
- # we'll remove them manually from this deny list, and their new repositories will become eligible.
- Feature.disabled?(:container_registry_migration_phase1_deny, project.root_ancestor) &&
- Feature.enabled?(:container_registry_migration_phase1_allow, project)
- rescue ContainerRegistry::Path::InvalidRegistryPathError => ex
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ex, **Gitlab::ApplicationContext.current)
- false
- end
-
##
# Because we do not have two way communication with registry yet,
# we create a container repository image resource when push to the
diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb
index a3e24844587..01fad14d036 100644
--- a/app/services/boards/base_items_list_service.rb
+++ b/app/services/boards/base_items_list_service.rb
@@ -12,21 +12,32 @@ module Boards
end
# rubocop: disable CodeReuse/ActiveRecord
- def metadata
- issuables = item_model.arel_table
- keys = metadata_fields.keys
+ def metadata(required_fields = [:issue_count, :total_issue_weight])
+ fields = metadata_fields(required_fields)
+ keys = fields.keys
# TODO: eliminate need for SQL literal fragment
- columns = Arel.sql(metadata_fields.values_at(*keys).join(', '))
- results = item_model.where(id: init_collection.select(issuables[:id])).pluck(columns)
+ columns = Arel.sql(fields.values_at(*keys).join(', '))
+ results = item_model.where(id: collection_ids)
+ results = query_additions(results, required_fields)
+ results = results.select(columns)
- Hash[keys.zip(results.flatten)]
+ Hash[keys.zip(results.pluck(columns).flatten)]
end
# rubocop: enable CodeReuse/ActiveRecord
private
- def metadata_fields
- { size: 'COUNT(*)' }
+ # override if needed
+ def query_additions(items, required_fields)
+ items
+ end
+
+ def collection_ids
+ @collection_ids ||= init_collection.select(item_model.arel_table[:id])
+ end
+
+ def metadata_fields(required_fields)
+ required_fields&.include?(:issue_count) ? { size: 'COUNT(*)' } : {}
end
def order(items)
diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb
index a7fe4c776b7..3a214122ed3 100644
--- a/app/services/bulk_create_integration_service.rb
+++ b/app/services/bulk_create_integration_service.rb
@@ -32,11 +32,7 @@ class BulkCreateIntegrationService
end
def integration_hash
- if integration.template?
- integration.to_integration_hash
- else
- integration.to_integration_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id }
- end
+ integration.to_integration_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id }
end
def data_fields_hash
diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb
index 097b29cf143..bc70dd3bea4 100644
--- a/app/services/ci/after_requeue_job_service.rb
+++ b/app/services/ci/after_requeue_job_service.rb
@@ -22,9 +22,15 @@ module Ci
end
def dependent_jobs
- stage_dependent_jobs
- .or(needs_dependent_jobs.except(:preload))
+ dependent_jobs = stage_dependent_jobs
+ .or(needs_dependent_jobs)
.ordered_by_stage
+
+ if ::Feature.enabled?(:ci_fix_order_of_subsequent_jobs, @processable.pipeline.project, default_enabled: :yaml)
+ dependent_jobs = ordered_by_dag(dependent_jobs)
+ end
+
+ dependent_jobs
end
def process(job)
@@ -44,5 +50,23 @@ module Ci
def skipped_jobs
@skipped_jobs ||= @processable.pipeline.processables.skipped
end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def ordered_by_dag(jobs)
+ sorted_job_names = sort_jobs(jobs).each_with_index.to_h
+
+ jobs.preload(:needs).group_by(&:stage_idx).flat_map do |_, stage_jobs|
+ stage_jobs.sort_by { |job| sorted_job_names.fetch(job.name) }
+ end
+ end
+
+ def sort_jobs(jobs)
+ Gitlab::Ci::YamlProcessor::Dag.order(
+ jobs.to_h do |job|
+ [job.name, job.needs.map(&:name)]
+ end
+ )
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb
index a2e53cbf9b8..034bab93108 100644
--- a/app/services/ci/create_downstream_pipeline_service.rb
+++ b/app/services/ci/create_downstream_pipeline_service.rb
@@ -120,15 +120,13 @@ module Ci
def has_cyclic_dependency?
return false if @bridge.triggers_child_pipeline?
- if Feature.enabled?(:ci_drop_cyclical_triggered_pipelines, @bridge.project, default_enabled: :yaml)
- pipeline_checksums = @bridge.pipeline.self_and_upstreams.filter_map do |pipeline|
- config_checksum(pipeline) unless pipeline.child?
- end
-
- # To avoid false positives we allow 1 cycle in the ancestry and
- # fail when 2 cycles are detected: A -> B -> A -> B -> A
- pipeline_checksums.tally.any? { |_checksum, occurrences| occurrences > 2 }
+ pipeline_checksums = @bridge.pipeline.self_and_upstreams.filter_map do |pipeline|
+ config_checksum(pipeline) unless pipeline.child?
end
+
+ # To avoid false positives we allow 1 cycle in the ancestry and
+ # fail when 2 cycles are detected: A -> B -> A -> B -> A
+ pipeline_checksums.tally.any? { |_checksum, occurrences| occurrences > 2 }
end
def has_max_descendants_depth?
diff --git a/app/services/ci/destroy_secure_file_service.rb b/app/services/ci/destroy_secure_file_service.rb
new file mode 100644
index 00000000000..7145ace7f31
--- /dev/null
+++ b/app/services/ci/destroy_secure_file_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Ci
+ class DestroySecureFileService < BaseService
+ def execute(secure_file)
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_secure_files, secure_file.project)
+
+ secure_file.destroy!
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
index 883a70c9795..4d1b2e07d7f 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
@@ -91,17 +91,13 @@ module Ci
def all_statuses_by_id
strong_memoize(:all_statuses_by_id) do
- all_statuses.to_h do |row|
- [row[:id], row]
- end
+ all_statuses.index_by { |row| row[:id] }
end
end
def all_statuses_by_name
strong_memoize(:statuses_by_name) do
- all_statuses.to_h do |row|
- [row[:name], row]
- end
+ all_statuses.index_by { |row| row[:name] }
end
end
diff --git a/app/services/ci/register_runner_service.rb b/app/services/ci/register_runner_service.rb
deleted file mode 100644
index 7c6cd82565d..00000000000
--- a/app/services/ci/register_runner_service.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class RegisterRunnerService
- def execute(registration_token, attributes)
- runner_type_attrs = extract_runner_type_attrs(registration_token)
-
- return unless runner_type_attrs
-
- ::Ci::Runner.create(attributes.merge(runner_type_attrs))
- end
-
- private
-
- def extract_runner_type_attrs(registration_token)
- @attrs_from_token ||= check_token(registration_token)
-
- return unless @attrs_from_token
-
- attrs = @attrs_from_token.clone
- case attrs[:runner_type]
- when :project_type
- attrs[:projects] = [attrs.delete(:scope)]
- when :group_type
- attrs[:groups] = [attrs.delete(:scope)]
- end
-
- attrs
- end
-
- def check_token(registration_token)
- if runner_registration_token_valid?(registration_token)
- # Create shared runner. Requires admin access
- { runner_type: :instance_type }
- elsif runner_registrar_valid?('project') && project = ::Project.find_by_runners_token(registration_token)
- # Create a specific runner for the project
- { runner_type: :project_type, scope: project }
- elsif runner_registrar_valid?('group') && group = ::Group.find_by_runners_token(registration_token)
- # Create a specific runner for the group
- { runner_type: :group_type, scope: group }
- end
- end
-
- def runner_registration_token_valid?(registration_token)
- ActiveSupport::SecurityUtils.secure_compare(registration_token, Gitlab::CurrentSettings.runners_registration_token)
- end
-
- def runner_registrar_valid?(type)
- Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?(type)
- end
-
- def token_scope
- @attrs_from_token[:scope]
- end
- end
-end
-
-Ci::RegisterRunnerService.prepend_mod
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 73c5d0163da..906e5cec4f3 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -65,7 +65,7 @@ module Ci
def check_access!(build)
unless can?(current_user, :update_build, build)
- raise Gitlab::Access::AccessDeniedError
+ raise Gitlab::Access::AccessDeniedError, '403 Forbidden'
end
end
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index 9ad46ca7585..d40643e1513 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -5,9 +5,8 @@ module Ci
include Gitlab::OptimisticLocking
def execute(pipeline)
- unless can?(current_user, :update_pipeline, pipeline)
- raise Gitlab::Access::AccessDeniedError
- end
+ access_response = check_access(pipeline)
+ return access_response if access_response.error?
pipeline.ensure_scheduling_type!
@@ -30,6 +29,18 @@ module Ci
Ci::ProcessPipelineService
.new(pipeline)
.execute
+
+ ServiceResponse.success
+ rescue Gitlab::Access::AccessDeniedError => e
+ ServiceResponse.error(message: e.message, http_status: :forbidden)
+ end
+
+ def check_access(pipeline)
+ if can?(current_user, :update_pipeline, pipeline)
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: '403 Forbidden', http_status: :forbidden)
+ end
end
private
diff --git a/app/services/ci/runners/assign_runner_service.rb b/app/services/ci/runners/assign_runner_service.rb
new file mode 100644
index 00000000000..886cd3a4e44
--- /dev/null
+++ b/app/services/ci/runners/assign_runner_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class AssignRunnerService
+ # @param [Ci::Runner] runner: the runner to assign to a project
+ # @param [Project] project: the new project to assign the runner to
+ # @param [User] user: the user performing the operation
+ def initialize(runner, project, user)
+ @runner = runner
+ @project = project
+ @user = user
+ end
+
+ def execute
+ return false unless @user.present? && @user.can?(:assign_runner, @runner)
+
+ @runner.assign_to(@project, @user)
+ end
+
+ private
+
+ attr_reader :runner, :project, :user
+ end
+ end
+end
+
+Ci::Runners::AssignRunnerService.prepend_mod
diff --git a/app/services/ci/runners/register_runner_service.rb b/app/services/ci/runners/register_runner_service.rb
new file mode 100644
index 00000000000..7978d094d9b
--- /dev/null
+++ b/app/services/ci/runners/register_runner_service.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class RegisterRunnerService
+ def execute(registration_token, attributes)
+ runner_type_attrs = extract_runner_type_attrs(registration_token)
+
+ return unless runner_type_attrs
+
+ ::Ci::Runner.create(attributes.merge(runner_type_attrs))
+ end
+
+ private
+
+ def extract_runner_type_attrs(registration_token)
+ @attrs_from_token ||= check_token(registration_token)
+
+ return unless @attrs_from_token
+
+ attrs = @attrs_from_token.clone
+ case attrs[:runner_type]
+ when :project_type
+ attrs[:projects] = [attrs.delete(:scope)]
+ when :group_type
+ attrs[:groups] = [attrs.delete(:scope)]
+ end
+
+ attrs
+ end
+
+ def check_token(registration_token)
+ if runner_registration_token_valid?(registration_token)
+ # Create shared runner. Requires admin access
+ { runner_type: :instance_type }
+ elsif runner_registrar_valid?('project') && project = ::Project.find_by_runners_token(registration_token)
+ # Create a specific runner for the project
+ { runner_type: :project_type, scope: project }
+ elsif runner_registrar_valid?('group') && group = ::Group.find_by_runners_token(registration_token)
+ # Create a specific runner for the group
+ { runner_type: :group_type, scope: group }
+ end
+ end
+
+ def runner_registration_token_valid?(registration_token)
+ ActiveSupport::SecurityUtils.secure_compare(registration_token, Gitlab::CurrentSettings.runners_registration_token)
+ end
+
+ def runner_registrar_valid?(type)
+ Feature.disabled?(:runner_registration_control, default_enabled: :yaml) || Gitlab::CurrentSettings.valid_runner_registrars.include?(type)
+ end
+
+ def token_scope
+ @attrs_from_token[:scope]
+ end
+ end
+ end
+end
+
+Ci::Runners::RegisterRunnerService.prepend_mod
diff --git a/app/services/ci/runners/reset_registration_token_service.rb b/app/services/ci/runners/reset_registration_token_service.rb
new file mode 100644
index 00000000000..bbe49c04644
--- /dev/null
+++ b/app/services/ci/runners/reset_registration_token_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class ResetRegistrationTokenService
+ # @param [ApplicationSetting, Project, Group] scope: the scope of the reset operation
+ # @param [User] user: the user performing the operation
+ def initialize(scope, user)
+ @scope = scope
+ @user = user
+ end
+
+ def execute
+ return unless @user.present? && @user.can?(:update_runners_registration_token, scope)
+
+ case scope
+ when ::ApplicationSetting
+ scope.reset_runners_registration_token!
+ ApplicationSetting.current_without_cache.runners_registration_token
+ when ::Group, ::Project
+ scope.reset_runners_token!
+ scope.runners_token
+ end
+ end
+
+ private
+
+ attr_reader :scope, :user
+ end
+ end
+end
diff --git a/app/services/ci/runners/unassign_runner_service.rb b/app/services/ci/runners/unassign_runner_service.rb
new file mode 100644
index 00000000000..1e46cf6add8
--- /dev/null
+++ b/app/services/ci/runners/unassign_runner_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class UnassignRunnerService
+ # @param [Ci::RunnerProject] runner_project the runner/project association to destroy
+ # @param [User] user the user performing the operation
+ def initialize(runner_project, user)
+ @runner_project = runner_project
+ @runner = runner_project.runner
+ @project = runner_project.project
+ @user = user
+ end
+
+ def execute
+ return false unless @user.present? && @user.can?(:assign_runner, @runner)
+
+ @runner_project.destroy
+ end
+
+ private
+
+ attr_reader :runner, :project, :user
+ end
+ end
+end
+
+Ci::Runners::UnassignRunnerService.prepend_mod
diff --git a/app/services/ci/runners/unregister_runner_service.rb b/app/services/ci/runners/unregister_runner_service.rb
new file mode 100644
index 00000000000..4ee1e73c458
--- /dev/null
+++ b/app/services/ci/runners/unregister_runner_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class UnregisterRunnerService
+ attr_reader :runner, :author
+
+ # @param [Ci::Runner] runner the runner to unregister/destroy
+ # @param [User, authentication token String] author the user or the authentication token that authorizes the removal
+ def initialize(runner, author)
+ @runner = runner
+ @author = author
+ end
+
+ def execute
+ @runner&.destroy
+ end
+ end
+ end
+end
+
+Ci::Runners::UnregisterRunnerService.prepend_mod
diff --git a/app/services/ci/runners/update_runner_service.rb b/app/services/ci/runners/update_runner_service.rb
new file mode 100644
index 00000000000..6cc080f81c2
--- /dev/null
+++ b/app/services/ci/runners/update_runner_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class UpdateRunnerService
+ attr_reader :runner
+
+ def initialize(runner)
+ @runner = runner
+ end
+
+ def update(params)
+ params[:active] = !params.delete(:paused) if params.include?(:paused)
+
+ runner.update(params).tap do |updated|
+ runner.tick_runner_queue if updated
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb
index a3f45c1b9cd..7323ad417ea 100644
--- a/app/services/ci/test_failure_history_service.rb
+++ b/app/services/ci/test_failure_history_service.rb
@@ -17,6 +17,7 @@ module Ci
MAX_TRACKABLE_FAILURES = 200
attr_reader :pipeline
+
delegate :project, to: :pipeline
def initialize(pipeline)
diff --git a/app/services/ci/unregister_runner_service.rb b/app/services/ci/unregister_runner_service.rb
deleted file mode 100644
index 97d9852b7ed..00000000000
--- a/app/services/ci/unregister_runner_service.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class UnregisterRunnerService
- attr_reader :runner
-
- # @param [Ci::Runner] runner the runner to unregister/destroy
- def initialize(runner)
- @runner = runner
- end
-
- def execute
- @runner&.destroy
- end
- end
-end
diff --git a/app/services/ci/update_runner_service.rb b/app/services/ci/update_runner_service.rb
deleted file mode 100644
index 4a17e25c0cc..00000000000
--- a/app/services/ci/update_runner_service.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class UpdateRunnerService
- attr_reader :runner
-
- def initialize(runner)
- @runner = runner
- end
-
- def update(params)
- params[:active] = !params.delete(:paused) if params.include?(:paused)
-
- runner.update(params).tap do |updated|
- runner.tick_runner_queue if updated
- end
- end
- end
-end
diff --git a/app/services/concerns/members/bulk_create_users.rb b/app/services/concerns/members/bulk_create_users.rb
index 9cfef96311e..3f8971dde74 100644
--- a/app/services/concerns/members/bulk_create_users.rb
+++ b/app/services/concerns/members/bulk_create_users.rb
@@ -47,16 +47,15 @@ module Members
end
end
- if user_ids.present?
- # we should handle the idea of existing members where users are passed as users - https://gitlab.com/gitlab-org/gitlab/-/issues/352617
- # the below will automatically discard invalid user_ids
- users.concat(User.id_in(user_ids))
+ # the below will automatically discard invalid user_ids
+ users.concat(User.id_in(user_ids)) if user_ids.present?
+ users.uniq! # de-duplicate just in case as there is no controlling if user records and ids are sent multiple times
+
+ if users.present?
# 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
+ existing_members = source.members_and_requesters.where(user_id: users).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
diff --git a/app/services/concerns/rate_limited_service.rb b/app/services/concerns/rate_limited_service.rb
index c8dc60355cf..5d7247a5b99 100644
--- a/app/services/concerns/rate_limited_service.rb
+++ b/app/services/concerns/rate_limited_service.rb
@@ -36,7 +36,6 @@ module RateLimitedService
def rate_limit!(service)
evaluated_scope = evaluated_scope_for(service)
- return if feature_flag_disabled?(evaluated_scope[:project])
if rate_limiter.throttled?(key, **opts.merge(scope: evaluated_scope.values, users_allowlist: users_allowlist))
raise RateLimitedError.new(key: key, rate_limiter: rate_limiter), _('This endpoint has been requested too many times. Try again later.')
@@ -54,14 +53,11 @@ module RateLimitedService
all[var] = service.public_send(var) # rubocop: disable GitlabSecurity/PublicSend
end
end
-
- def feature_flag_disabled?(project)
- Feature.disabled?("rate_limited_service_#{key}", project, default_enabled: :yaml)
- end
end
prepended do
attr_accessor :rate_limiter_bypassed
+
cattr_accessor :rate_limiter_scoped_and_keyed
def self.rate_limit(key:, opts:, rate_limiter: ::Gitlab::ApplicationRateLimiter)
diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb
index cbcd0b7f56b..b21d05f4178 100644
--- a/app/services/concerns/update_repository_storage_methods.rb
+++ b/app/services/concerns/update_repository_storage_methods.rb
@@ -6,6 +6,7 @@ module UpdateRepositoryStorageMethods
Error = Class.new(StandardError)
attr_reader :repository_storage_move
+
delegate :container, :source_storage_name, :destination_storage_name, to: :repository_storage_move
def initialize(repository_storage_move)
diff --git a/app/services/error_tracking/base_service.rb b/app/services/error_tracking/base_service.rb
index 289c125b9d1..598621f70e1 100644
--- a/app/services/error_tracking/base_service.rb
+++ b/app/services/error_tracking/base_service.rb
@@ -1,7 +1,13 @@
# frozen_string_literal: true
module ErrorTracking
- class BaseService < ::BaseService
+ class BaseService < ::BaseProjectService
+ include Gitlab::Utils::UsageData
+
+ def initialize(project, user = nil, params = {})
+ super(project: project, current_user: user, params: params.dup)
+ end
+
def execute
return unauthorized if unauthorized
@@ -21,6 +27,8 @@ module ErrorTracking
yield if block_given?
+ track_usage_event(params[:tracking_event], current_user.id) if params[:tracking_event]
+
success(parse_response(response))
end
diff --git a/app/services/error_tracking/collect_error_service.rb b/app/services/error_tracking/collect_error_service.rb
index 50508c9810a..6376b743255 100644
--- a/app/services/error_tracking/collect_error_service.rb
+++ b/app/services/error_tracking/collect_error_service.rb
@@ -60,7 +60,7 @@ module ErrorTracking
end
def actor
- return event['transaction'] if event['transaction']
+ return event['transaction'] if event['transaction'].present?
# Some SDKs do not have a transaction attribute.
# So we build it by combining function name and module name from
diff --git a/app/services/google_cloud/create_service_accounts_service.rb b/app/services/google_cloud/create_service_accounts_service.rb
index e360b3a8e4e..51d08cc5b55 100644
--- a/app/services/google_cloud/create_service_accounts_service.rb
+++ b/app/services/google_cloud/create_service_accounts_service.rb
@@ -12,7 +12,7 @@ module GoogleCloud
service_account.project_id,
service_account.to_json,
service_account_key.to_json,
- environment_protected?
+ ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name)
)
ServiceResponse.success(message: _('Service account generated successfully'), payload: {
@@ -50,11 +50,6 @@ module GoogleCloud
def service_account_desc
"GitLab generated service account for project '#{project.name}' and environment '#{environment_name}'"
end
-
- # Overridden in EE
- def environment_protected?
- false
- end
end
end
diff --git a/app/services/google_cloud/gcp_region_add_or_replace_service.rb b/app/services/google_cloud/gcp_region_add_or_replace_service.rb
new file mode 100644
index 00000000000..467f818bcc7
--- /dev/null
+++ b/app/services/google_cloud/gcp_region_add_or_replace_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+ class GcpRegionAddOrReplaceService < ::BaseService
+ def execute(environment, region)
+ gcp_region_key = Projects::GoogleCloudController::GCP_REGION_CI_VAR_KEY
+
+ change_params = { variable_params: { key: gcp_region_key, value: region, environment_scope: environment } }
+ filter_params = { key: gcp_region_key, filter: { environment_scope: environment } }
+
+ existing_variable = ::Ci::VariablesFinder.new(project, filter_params).execute.first
+
+ if existing_variable
+ change_params[:action] = :update
+ change_params[:variable] = existing_variable
+ else
+ change_params[:action] = :create
+ end
+
+ ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute
+ end
+ end
+end
diff --git a/app/services/google_cloud/service_accounts_service.rb b/app/services/google_cloud/service_accounts_service.rb
index 3014daf08e2..b791f07cd65 100644
--- a/app/services/google_cloud/service_accounts_service.rb
+++ b/app/services/google_cloud/service_accounts_service.rb
@@ -14,12 +14,12 @@ module GoogleCloud
#
# This method looks up GitLab project's CI vars
# and returns Google Cloud Service Accounts combinations
- # aligning GitLab project and environment to GCP projects
+ # aligning GitLab project and ref to GCP projects
def find_for_project
- group_vars_by_environment.map do |environment_scope, value|
+ group_vars_by_ref.map do |environment_scope, value|
{
- environment: environment_scope,
+ ref: environment_scope,
gcp_project: value['GCP_PROJECT_ID'],
service_account_exists: value['GCP_SERVICE_ACCOUNT'].present?,
service_account_key_exists: value['GCP_SERVICE_ACCOUNT_KEY'].present?
@@ -27,21 +27,21 @@ module GoogleCloud
end
end
- def add_for_project(environment, gcp_project_id, service_account, service_account_key, is_protected)
+ def add_for_project(ref, gcp_project_id, service_account, service_account_key, is_protected)
project_var_create_or_replace(
- environment,
+ ref,
'GCP_PROJECT_ID',
gcp_project_id,
is_protected
)
project_var_create_or_replace(
- environment,
+ ref,
'GCP_SERVICE_ACCOUNT',
service_account,
is_protected
)
project_var_create_or_replace(
- environment,
+ ref,
'GCP_SERVICE_ACCOUNT_KEY',
service_account_key,
is_protected
@@ -50,7 +50,7 @@ module GoogleCloud
private
- def group_vars_by_environment
+ def group_vars_by_ref
filtered_vars = project.variables.filter { |variable| GCP_KEYS.include? variable.key }
filtered_vars.each_with_object({}) do |variable, grouped|
grouped[variable.environment_scope] ||= {}
@@ -59,10 +59,19 @@ module GoogleCloud
end
def project_var_create_or_replace(environment_scope, key, value, is_protected)
- params = { key: key, filter: { environment_scope: environment_scope } }
- existing_variable = ::Ci::VariablesFinder.new(project, params).execute.first
- existing_variable.destroy if existing_variable
- project.variables.create!(key: key, value: value, environment_scope: environment_scope, protected: is_protected)
+ change_params = { variable_params: { key: key, value: value, environment_scope: environment_scope, protected: is_protected } }
+ filter_params = { key: key, filter: { environment_scope: environment_scope } }
+
+ existing_variable = ::Ci::VariablesFinder.new(project, filter_params).execute.first
+
+ if existing_variable
+ change_params[:action] = :update
+ change_params[:variable] = existing_variable
+ else
+ change_params[:action] = :create
+ end
+
+ ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute
end
end
end
diff --git a/app/services/groups/deploy_tokens/create_service.rb b/app/services/groups/deploy_tokens/create_service.rb
index aee423659ef..4b0541e78a1 100644
--- a/app/services/groups/deploy_tokens/create_service.rb
+++ b/app/services/groups/deploy_tokens/create_service.rb
@@ -13,3 +13,5 @@ module Groups
end
end
end
+
+Groups::DeployTokens::CreateService.prepend_mod
diff --git a/app/services/groups/deploy_tokens/destroy_service.rb b/app/services/groups/deploy_tokens/destroy_service.rb
index 6dae22f29d2..4745d00ed7f 100644
--- a/app/services/groups/deploy_tokens/destroy_service.rb
+++ b/app/services/groups/deploy_tokens/destroy_service.rb
@@ -11,3 +11,5 @@ module Groups
end
end
end
+
+Groups::DeployTokens::DestroyService.prepend_mod
diff --git a/app/services/groups/deploy_tokens/revoke_service.rb b/app/services/groups/deploy_tokens/revoke_service.rb
new file mode 100644
index 00000000000..cf91d3b27fa
--- /dev/null
+++ b/app/services/groups/deploy_tokens/revoke_service.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Groups
+ module DeployTokens
+ class RevokeService < BaseService
+ attr_accessor :token
+
+ def execute
+ @token = group.deploy_tokens.find(params[:id])
+ @token.revoke!
+ end
+ end
+ end
+end
+
+Groups::DeployTokens::RevokeService.prepend_mod
diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb
index 5ffa746e109..c88c139a22e 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -11,11 +11,15 @@ module Groups
# rubocop: disable CodeReuse/ActiveRecord
def execute
+ # TODO - add a policy check here https://gitlab.com/gitlab-org/gitlab/-/issues/353082
+ raise DestroyError, "You can't delete this group because you're blocked." if current_user.blocked?
+
group.prepare_for_destroy
group.projects.includes(:project_feature).each do |project|
# Execute the destruction of the models immediately to ensure atomic cleanup.
success = ::Projects::DestroyService.new(project, current_user).execute
+
raise DestroyError, "Project #{project.id} can't be deleted" unless success
end
diff --git a/app/services/import/gitlab_projects/create_project_from_remote_file_service.rb b/app/services/import/gitlab_projects/create_project_from_remote_file_service.rb
deleted file mode 100644
index edb9dc8ad91..00000000000
--- a/app/services/import/gitlab_projects/create_project_from_remote_file_service.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-module Import
- module GitlabProjects
- class CreateProjectFromRemoteFileService < CreateProjectFromUploadedFileService
- FILE_SIZE_LIMIT = 10.gigabytes
- ALLOWED_CONTENT_TYPES = [
- 'application/gzip', # most common content-type when fetching a tar.gz
- 'application/x-tar' # aws-s3 uses x-tar for tar.gz files
- ].freeze
-
- validate :valid_remote_import_url?
- validate :validate_file_size
- validate :validate_content_type
-
- private
-
- def required_params
- [:path, :namespace, :remote_import_url]
- end
-
- def project_params
- super
- .except(:file)
- .merge(import_export_upload: ::ImportExportUpload.new(
- remote_import_url: params[:remote_import_url]
- ))
- end
-
- def valid_remote_import_url?
- ::Gitlab::UrlBlocker.validate!(
- params[:remote_import_url],
- allow_localhost: allow_local_requests?,
- allow_local_network: allow_local_requests?,
- schemes: %w(http https)
- )
-
- true
- rescue ::Gitlab::UrlBlocker::BlockedUrlError => e
- errors.add(:base, e.message)
-
- false
- end
-
- def allow_local_requests?
- ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services?
- end
-
- def validate_content_type
- # AWS-S3 presigned URLs don't respond to HTTP HEAD requests,
- # so file type cannot be validated
- # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75170#note_748059103
- return if amazon_s3?
-
- if headers['content-type'].blank?
- errors.add(:base, "Missing 'ContentType' header")
- elsif !ALLOWED_CONTENT_TYPES.include?(headers['content-type'])
- errors.add(:base, "Remote file content type '%{content_type}' not allowed. (Allowed content types: %{allowed})" % {
- content_type: headers['content-type'],
- allowed: ALLOWED_CONTENT_TYPES.join(', ')
- })
- end
- end
-
- def validate_file_size
- # AWS-S3 presigned URLs don't respond to HTTP HEAD requests,
- # so file size cannot be validated
- # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75170#note_748059103
- return if amazon_s3?
-
- if headers['content-length'].to_i == 0
- errors.add(:base, "Missing 'ContentLength' header")
- elsif headers['content-length'].to_i > FILE_SIZE_LIMIT
- errors.add(:base, 'Remote file larger than limit. (limit %{limit})' % {
- limit: ActiveSupport::NumberHelper.number_to_human_size(FILE_SIZE_LIMIT)
- })
- end
- end
-
- def amazon_s3?
- headers['Server'] == 'AmazonS3' && headers['x-amz-request-id'].present?
- end
-
- def headers
- return {} if params[:remote_import_url].blank? || !valid_remote_import_url?
-
- @headers ||= Gitlab::HTTP.head(params[:remote_import_url]).headers
- end
- end
- end
-end
diff --git a/app/services/import/gitlab_projects/create_project_from_uploaded_file_service.rb b/app/services/import/gitlab_projects/create_project_from_uploaded_file_service.rb
deleted file mode 100644
index 35d52a11288..00000000000
--- a/app/services/import/gitlab_projects/create_project_from_uploaded_file_service.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-module Import
- module GitlabProjects
- class CreateProjectFromUploadedFileService
- include ActiveModel::Validations
- include ::Services::ReturnServiceResponses
-
- validate :required_params_presence
-
- def initialize(current_user, params = {})
- @current_user = current_user
- @params = params.dup
- end
-
- def execute
- return error(errors.full_messages.first) unless valid?
- return error(project.errors.full_messages&.first) unless project.saved?
-
- success(project)
- rescue StandardError => e
- error(e.message)
- end
-
- private
-
- attr_reader :current_user, :params
-
- def error(message)
- super(message, :bad_request)
- end
-
- def project
- @project ||= ::Projects::GitlabProjectsImportService.new(
- current_user,
- project_params,
- params[:override]
- ).execute
- end
-
- def project_params
- {
- name: params[:name],
- path: params[:path],
- namespace_id: params[:namespace].id,
- file: params[:file],
- overwrite: params[:overwrite],
- import_type: 'gitlab_project'
- }
- end
-
- def required_params
- [:path, :namespace, :file]
- end
-
- def required_params_presence
- required_params
- .select { |key| params[key].blank? }
- .each do |missing_parameter|
- errors.add(:base, "Parameter '#{missing_parameter}' is required")
- end
- end
- end
- end
-end
diff --git a/app/services/import/gitlab_projects/create_project_service.rb b/app/services/import/gitlab_projects/create_project_service.rb
new file mode 100644
index 00000000000..1613c4dde25
--- /dev/null
+++ b/app/services/import/gitlab_projects/create_project_service.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+# Creates a new project with an associated project export file to be imported
+# The associated project export file might be associated with different strategies
+# to acquire the file to be imported, the default file_acquisition_strategy
+# is uploading a file (Import::GitlabProjects::FileAcquisitionStrategies::FileUpload)
+module Import
+ module GitlabProjects
+ class CreateProjectService
+ include ActiveModel::Validations
+ include ::Services::ReturnServiceResponses
+
+ validates_presence_of :path, :namespace
+
+ # Creates a new CreateProjectService.
+ #
+ # @param [User] current_user
+ # @param [Hash] :params
+ # @param [Import::GitlabProjects::FileAcquisitionStrategies::*] :file_acquisition_strategy
+ def initialize(current_user, params:, file_acquisition_strategy: FileAcquisitionStrategies::FileUpload)
+ @current_user = current_user
+ @params = params.dup
+ @strategy = file_acquisition_strategy.new(current_user: current_user, params: params)
+ end
+
+ # Creates a project with the strategy parameters
+ #
+ # @return [Services::ServiceReponse]
+ def execute
+ return error(errors.full_messages) unless valid?
+ return error(project.errors.full_messages) unless project.saved?
+
+ success(project)
+ rescue StandardError => e
+ error(e.message)
+ end
+
+ # Cascade the validation to strategy
+ def valid?
+ super && strategy.valid?
+ end
+
+ # Merge with strategy's errors
+ def errors
+ super.tap { _1.merge!(strategy.errors) }
+ end
+
+ def read_attribute_for_validation(key)
+ params[key]
+ end
+
+ private
+
+ attr_reader :current_user, :params, :strategy
+
+ def error(messages)
+ messages = Array.wrap(messages)
+ message = messages.shift
+ super(message, :bad_request, pass_back: { other_errors: messages })
+ end
+
+ def project
+ @project ||= ::Projects::GitlabProjectsImportService.new(
+ current_user,
+ project_params,
+ params[:override]
+ ).execute
+ end
+
+ def project_params
+ {
+ name: params[:name],
+ path: params[:path],
+ namespace_id: params[:namespace].id,
+ overwrite: params[:overwrite],
+ import_type: 'gitlab_project'
+ }.merge(strategy.project_params)
+ end
+ end
+ end
+end
diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb
new file mode 100644
index 00000000000..8bee3067d6c
--- /dev/null
+++ b/app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Import
+ module GitlabProjects
+ module FileAcquisitionStrategies
+ class FileUpload
+ include ActiveModel::Validations
+
+ validate :uploaded_file
+
+ def initialize(current_user: nil, params:)
+ @params = params
+ end
+
+ def project_params
+ @project_params ||= @params.slice(:file)
+ end
+
+ def file
+ @file ||= @params[:file]
+ end
+
+ private
+
+ def uploaded_file
+ return if file.present? && file.is_a?(UploadedFile)
+
+ errors.add(:file, 'must be uploaded')
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb
new file mode 100644
index 00000000000..ae9a450660c
--- /dev/null
+++ b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Import
+ module GitlabProjects
+ module FileAcquisitionStrategies
+ class RemoteFile
+ include ActiveModel::Validations
+
+ def self.allow_local_requests?
+ ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services?
+ end
+
+ validates :file_url, addressable_url: {
+ schemes: %w(https),
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ dns_rebind_protection: true
+ }
+ validate :aws_s3, if: :validate_aws_s3?
+ # When removing the import_project_from_remote_file_s3 remove the
+ # whole condition of this validation:
+ validates_with RemoteFileValidator, if: -> { validate_aws_s3? || !s3_request? }
+
+ def initialize(current_user: nil, params:)
+ @params = params
+ end
+
+ def project_params
+ @project_parms ||= {
+ import_export_upload: ::ImportExportUpload.new(remote_import_url: file_url)
+ }
+ end
+
+ def file_url
+ @file_url ||= params[:remote_import_url]
+ end
+
+ def content_type
+ @content_type ||= headers['content-type']
+ end
+
+ def content_length
+ @content_length ||= headers['content-length'].to_i
+ end
+
+ private
+
+ attr_reader :params
+
+ def aws_s3
+ if s3_request?
+ errors.add(:base, 'To import from AWS S3 use `projects/remote-import-s3`')
+ end
+ end
+
+ def s3_request?
+ headers['Server'] == 'AmazonS3' && headers['x-amz-request-id'].present?
+ end
+
+ def validate_aws_s3?
+ ::Feature.enabled?(:import_project_from_remote_file_s3, default_enabled: :yaml)
+ end
+
+ def headers
+ return {} if file_url.blank?
+
+ @headers ||= Gitlab::HTTP.head(file_url, timeout: 1.second).headers
+ rescue StandardError => e
+ errors.add(:base, "Failed to retrive headers: #{e.message}")
+
+ {}
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb
new file mode 100644
index 00000000000..5cbca53582d
--- /dev/null
+++ b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module Import
+ module GitlabProjects
+ module FileAcquisitionStrategies
+ class RemoteFileS3
+ include ActiveModel::Validations
+ include Gitlab::Utils::StrongMemoize
+
+ def self.allow_local_requests?
+ ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services?
+ end
+
+ validates_presence_of :region, :bucket_name, :file_key, :access_key_id, :secret_access_key
+ validates :file_url, addressable_url: {
+ schemes: %w(https),
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ dns_rebind_protection: true
+ }
+
+ validates_with RemoteFileValidator
+
+ # The import itself has a limit of 24h, since the URL is created before the import starts
+ # we add an expiration a bit longer to ensure it won't expire during the import.
+ URL_EXPIRATION = 28.hours.seconds
+
+ def initialize(current_user: nil, params:)
+ @params = params
+ end
+
+ def project_params
+ @project_parms ||= {
+ import_export_upload: ::ImportExportUpload.new(remote_import_url: file_url)
+ }
+ end
+
+ def file_url
+ @file_url ||= s3_object&.presigned_url(:get, expires_in: URL_EXPIRATION.to_i)
+ end
+
+ def content_type
+ @content_type ||= s3_object&.content_type
+ end
+
+ def content_length
+ @content_length ||= s3_object&.content_length.to_i
+ end
+
+ # Make the validated params/methods accessible
+ def read_attribute_for_validation(key)
+ return file_url if key == :file_url
+
+ params[key]
+ end
+
+ private
+
+ attr_reader :params
+
+ def s3_object
+ strong_memoize(:s3_object) do
+ build_s3_options
+ end
+ end
+
+ def build_s3_options
+ object = Aws::S3::Object.new(
+ params[:bucket_name],
+ params[:file_key],
+ client: Aws::S3::Client.new(
+ region: params[:region],
+ access_key_id: params[:access_key_id],
+ secret_access_key: params[:secret_access_key]
+ )
+ )
+
+ # Force validate if the object exists and is accessible
+ # Some exceptions are only raised when trying to access the object data
+ unless object.exists?
+ errors.add(:base, "File not found '#{params[:file_key]}' in '#{params[:bucket_name]}'")
+ return
+ end
+
+ object
+ rescue StandardError => e
+ errors.add(:base, "Failed to open '#{params[:file_key]}' in '#{params[:bucket_name]}': #{e.message}")
+ nil
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/incident_management/pager_duty/process_webhook_service.rb b/app/services/incident_management/pager_duty/process_webhook_service.rb
index ccbca671b37..a49e639ea62 100644
--- a/app/services/incident_management/pager_duty/process_webhook_service.rb
+++ b/app/services/incident_management/pager_duty/process_webhook_service.rb
@@ -2,7 +2,7 @@
module IncidentManagement
module PagerDuty
- class ProcessWebhookService
+ class ProcessWebhookService < ::BaseProjectService
include Gitlab::Utils::StrongMemoize
include IncidentManagement::Settings
@@ -13,7 +13,8 @@ module IncidentManagement
PAGER_DUTY_PROCESSABLE_EVENT_TYPES = %w(incident.trigger).freeze
def initialize(project, payload)
- @project = project
+ super(project: project)
+
@payload = payload
end
@@ -29,7 +30,7 @@ module IncidentManagement
private
- attr_reader :project, :payload
+ attr_reader :payload
def process_incidents
pager_duty_processable_events.each do |event|
diff --git a/app/services/integrations/propagate_template_service.rb b/app/services/integrations/propagate_template_service.rb
deleted file mode 100644
index 85a82ba4c8e..00000000000
--- a/app/services/integrations/propagate_template_service.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-module Integrations
- # TODO: Remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/335178
- class PropagateTemplateService
- def self.propagate(_integration)
- # no-op
- end
- end
-end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 95093b88155..a63c54df4a6 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -160,7 +160,7 @@ class IssuableBaseService < ::BaseProjectService
params.delete(:escalation_status)
).execute
- return unless result.success? && result.payload.present?
+ return unless result.success? && result[:escalation_status].present?
@escalation_status_change_reason = result[:escalation_status].delete(:status_change_reason)
@@ -486,7 +486,10 @@ class IssuableBaseService < ::BaseProjectService
associations[:description] = issuable.description
associations[:reviewers] = issuable.reviewers.to_a if issuable.allows_reviewers?
associations[:severity] = issuable.severity if issuable.supports_severity?
- associations[:escalation_status] = issuable.escalation_status&.slice(:status, :policy_id) if issuable.supports_escalation?
+
+ if issuable.supports_escalation? && issuable.escalation_status
+ associations[:escalation_status] = issuable.escalation_status.status_name
+ end
associations
end
diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb
index 81685f81afa..802260c8fae 100644
--- a/app/services/issuable_links/create_service.rb
+++ b/app/services/issuable_links/create_service.rb
@@ -17,7 +17,7 @@ module IssuableLinks
# otherwise create issue links for the issues which
# are still not assigned and return success message.
if render_conflict_error?
- return error(issuables_assigned_message, 409)
+ return error(issuables_already_assigned_message, 409)
end
if render_not_found_error?
@@ -36,6 +36,20 @@ module IssuableLinks
success
end
+ # rubocop: disable CodeReuse/ActiveRecord
+ def relate_issuables(referenced_issuable)
+ link = link_class.find_or_initialize_by(source: issuable, target: referenced_issuable)
+
+ set_link_type(link)
+
+ if link.changed? && link.save
+ create_notes(referenced_issuable)
+ end
+
+ link
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
private
def render_conflict_error?
@@ -96,6 +110,23 @@ module IssuableLinks
{}
end
+ def issuables_already_assigned_message
+ _('%{issuable}(s) already assigned' % { issuable: target_issuable_type.capitalize })
+ end
+
+ def issuables_not_found_message
+ _('No matching %{issuable} found. Make sure that you are adding a valid %{issuable} URL.' % { issuable: target_issuable_type })
+ end
+
+ def target_issuable_type
+ :issue
+ end
+
+ def create_notes(referenced_issuable)
+ SystemNoteService.relate_issuable(issuable, referenced_issuable, current_user)
+ SystemNoteService.relate_issuable(referenced_issuable, issuable, current_user)
+ end
+
def linkable_issuables(objects)
raise NotImplementedError
end
@@ -104,16 +135,12 @@ module IssuableLinks
raise NotImplementedError
end
- def relate_issuables(referenced_object)
+ def link_class
raise NotImplementedError
end
- def issuables_assigned_message
- _("Issue(s) already assigned")
- end
-
- def issuables_not_found_message
- _("No matching issue found. Make sure that you are adding a valid issue URL.")
+ def set_link_type(_link)
+ # no-op
end
end
end
diff --git a/app/services/issuable_links/destroy_service.rb b/app/services/issuable_links/destroy_service.rb
index 28035bbb291..19edd008b0a 100644
--- a/app/services/issuable_links/destroy_service.rb
+++ b/app/services/issuable_links/destroy_service.rb
@@ -4,11 +4,13 @@ module IssuableLinks
class DestroyService < BaseService
include IncidentManagement::UsageData
- attr_reader :link, :current_user
+ attr_reader :link, :current_user, :source, :target
def initialize(link, user)
@link = link
@current_user = user
+ @source = link.source
+ @target = link.target
end
def execute
@@ -22,6 +24,11 @@ module IssuableLinks
private
+ def create_notes
+ SystemNoteService.unrelate_issuable(source, target, current_user)
+ SystemNoteService.unrelate_issuable(target, source, current_user)
+ end
+
def after_destroy
create_notes
track_event
diff --git a/app/services/issue_links/create_service.rb b/app/services/issue_links/create_service.rb
index a022d3e0bcf..1c6621ce0a1 100644
--- a/app/services/issue_links/create_service.rb
+++ b/app/services/issue_links/create_service.rb
@@ -2,44 +2,25 @@
module IssueLinks
class CreateService < IssuableLinks::CreateService
- # rubocop: disable CodeReuse/ActiveRecord
- def relate_issuables(referenced_issue)
- link = IssueLink.find_or_initialize_by(source: issuable, target: referenced_issue)
-
- set_link_type(link)
-
- if link.changed? && link.save
- create_notes(referenced_issue)
- end
-
- link
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def linkable_issuables(issues)
@linkable_issuables ||= begin
issues.select { |issue| can?(current_user, :admin_issue_link, issue) }
end
end
- def create_notes(referenced_issue)
- SystemNoteService.relate_issue(issuable, referenced_issue, current_user)
- SystemNoteService.relate_issue(referenced_issue, issuable, current_user)
- end
-
def previous_related_issuables
@related_issues ||= issuable.related_issues(current_user).to_a
end
private
- def set_link_type(_link)
- # EE only
- end
-
def track_event
track_incident_action(current_user, issuable, :incident_relate)
end
+
+ def link_class
+ IssueLink
+ end
end
end
diff --git a/app/services/issue_links/destroy_service.rb b/app/services/issue_links/destroy_service.rb
index 25a45fc697b..e2422ecaca9 100644
--- a/app/services/issue_links/destroy_service.rb
+++ b/app/services/issue_links/destroy_service.rb
@@ -4,23 +4,10 @@ module IssueLinks
class DestroyService < IssuableLinks::DestroyService
private
- def source
- @source ||= link.source
- end
-
- def target
- @target ||= link.target
- end
-
def permission_to_remove_relation?
can?(current_user, :admin_issue_link, source) && can?(current_user, :admin_issue_link, target)
end
- def create_notes
- SystemNoteService.unrelate_issue(source, target, current_user)
- SystemNoteService.unrelate_issue(target, source, current_user)
- end
-
def track_event
track_incident_action(current_user, target, :incident_unrelate)
end
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 7fbf7c6af58..7ab663718db 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -23,6 +23,7 @@ module Issues
handle_move_between_ids(@issue)
+ @add_related_issue ||= params.delete(:add_related_issue)
filter_resolve_discussion_params
create(@issue, skip_system_notes: skip_system_notes)
@@ -52,6 +53,7 @@ module Issues
# Add new items to Issues::AfterCreateService if they can be performed in Sidekiq
def after_create(issue)
user_agent_detail_service.create
+ handle_add_related_issue(issue)
resolve_discussions_with_issue(issue)
create_escalation_status(issue)
@@ -91,6 +93,12 @@ module Issues
def user_agent_detail_service
UserAgentDetailService.new(spammable: @issue, spam_params: spam_params)
end
+
+ def handle_add_related_issue(issue)
+ return unless @add_related_issue
+
+ IssueLinks::CreateService.new(issue, issue.author, { target_issuable: @add_related_issue }).execute
+ end
end
end
diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb
index 3809d8bc347..7076e858155 100644
--- a/app/services/issues/export_csv_service.rb
+++ b/app/services/issues/export_csv_service.rb
@@ -23,11 +23,11 @@ module Issues
def header_to_value_hash
{
+ 'Title' => 'title',
+ 'Description' => 'description',
'Issue ID' => 'iid',
'URL' => -> (issue) { issue_url(issue) },
- 'Title' => 'title',
'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' },
- 'Description' => 'description',
'Author' => 'author_name',
'Author Username' => -> (issue) { issue.author&.username },
'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') },
@@ -52,7 +52,7 @@ module Issues
# rubocop: disable CodeReuse/ActiveRecord
def issue_time_spent(issue)
- issue.timelogs.map(&:time_spent).sum
+ issue.timelogs.sum(&:time_spent)
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/services/issues/set_crm_contacts_service.rb b/app/services/issues/set_crm_contacts_service.rb
index 2edc944435b..5836097f1fd 100644
--- a/app/services/issues/set_crm_contacts_service.rb
+++ b/app/services/issues/set_crm_contacts_service.rb
@@ -52,7 +52,7 @@ module Issues
end
def add_by_email
- contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group, emails(:add_emails))
+ contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group.root_ancestor, emails(:add_emails))
add_by_id(contact_ids)
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 8372cd919e5..88c4ff1a8bb 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -51,7 +51,6 @@ module Issues
old_mentioned_users = old_associations.fetch(:mentioned_users, [])
old_assignees = old_associations.fetch(:assignees, [])
old_severity = old_associations[:severity]
- old_escalation_status = old_associations[:escalation_status]
if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
todo_service.resolve_todos_for_target(issue, current_user)
@@ -68,7 +67,7 @@ module Issues
handle_milestone_change(issue)
handle_added_mentions(issue, old_mentioned_users)
handle_severity_change(issue, old_severity)
- handle_escalation_status_change(issue, old_escalation_status)
+ handle_escalation_status_change(issue)
handle_issue_type_change(issue)
end
@@ -80,9 +79,7 @@ module Issues
todo_service.reassigned_assignable(issue, current_user, old_assignees)
track_incident_action(current_user, issue, :incident_assigned)
- if Feature.enabled?(:broadcast_issue_updates, issue.project, default_enabled: :yaml)
- GraphqlTriggers.issuable_assignees_updated(issue)
- end
+ GraphqlTriggers.issuable_assignees_updated(issue)
end
def handle_task_changes(issuable)
@@ -196,9 +193,8 @@ module Issues
::IncidentManagement::AddSeveritySystemNoteWorker.perform_async(issue.id, current_user.id)
end
- def handle_escalation_status_change(issue, old_escalation_status)
- return unless old_escalation_status.present?
- return if issue.escalation_status&.slice(:status, :policy_id) == old_escalation_status
+ def handle_escalation_status_change(issue)
+ return unless issue.supports_escalation? && issue.escalation_status
::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.new(
issue,
diff --git a/app/services/labels/base_service.rb b/app/services/labels/base_service.rb
index ead7f2ea607..f694e6d47a0 100644
--- a/app/services/labels/base_service.rb
+++ b/app/services/labels/base_service.rb
@@ -2,162 +2,8 @@
module Labels
class BaseService < ::BaseService
- COLOR_NAME_TO_HEX = {
- black: '#000000',
- silver: '#C0C0C0',
- gray: '#808080',
- white: '#FFFFFF',
- maroon: '#800000',
- red: '#FF0000',
- purple: '#800080',
- fuchsia: '#FF00FF',
- green: '#008000',
- lime: '#00FF00',
- olive: '#808000',
- yellow: '#FFFF00',
- navy: '#000080',
- blue: '#0000FF',
- teal: '#008080',
- aqua: '#00FFFF',
- orange: '#FFA500',
- aliceblue: '#F0F8FF',
- antiquewhite: '#FAEBD7',
- aquamarine: '#7FFFD4',
- azure: '#F0FFFF',
- beige: '#F5F5DC',
- bisque: '#FFE4C4',
- blanchedalmond: '#FFEBCD',
- blueviolet: '#8A2BE2',
- brown: '#A52A2A',
- burlywood: '#DEB887',
- cadetblue: '#5F9EA0',
- chartreuse: '#7FFF00',
- chocolate: '#D2691E',
- coral: '#FF7F50',
- cornflowerblue: '#6495ED',
- cornsilk: '#FFF8DC',
- crimson: '#DC143C',
- darkblue: '#00008B',
- darkcyan: '#008B8B',
- darkgoldenrod: '#B8860B',
- darkgray: '#A9A9A9',
- darkgreen: '#006400',
- darkgrey: '#A9A9A9',
- darkkhaki: '#BDB76B',
- darkmagenta: '#8B008B',
- darkolivegreen: '#556B2F',
- darkorange: '#FF8C00',
- darkorchid: '#9932CC',
- darkred: '#8B0000',
- darksalmon: '#E9967A',
- darkseagreen: '#8FBC8F',
- darkslateblue: '#483D8B',
- darkslategray: '#2F4F4F',
- darkslategrey: '#2F4F4F',
- darkturquoise: '#00CED1',
- darkviolet: '#9400D3',
- deeppink: '#FF1493',
- deepskyblue: '#00BFFF',
- dimgray: '#696969',
- dimgrey: '#696969',
- dodgerblue: '#1E90FF',
- firebrick: '#B22222',
- floralwhite: '#FFFAF0',
- forestgreen: '#228B22',
- gainsboro: '#DCDCDC',
- ghostwhite: '#F8F8FF',
- gold: '#FFD700',
- goldenrod: '#DAA520',
- greenyellow: '#ADFF2F',
- grey: '#808080',
- honeydew: '#F0FFF0',
- hotpink: '#FF69B4',
- indianred: '#CD5C5C',
- indigo: '#4B0082',
- ivory: '#FFFFF0',
- khaki: '#F0E68C',
- lavender: '#E6E6FA',
- lavenderblush: '#FFF0F5',
- lawngreen: '#7CFC00',
- lemonchiffon: '#FFFACD',
- lightblue: '#ADD8E6',
- lightcoral: '#F08080',
- lightcyan: '#E0FFFF',
- lightgoldenrodyellow: '#FAFAD2',
- lightgray: '#D3D3D3',
- lightgreen: '#90EE90',
- lightgrey: '#D3D3D3',
- lightpink: '#FFB6C1',
- lightsalmon: '#FFA07A',
- lightseagreen: '#20B2AA',
- lightskyblue: '#87CEFA',
- lightslategray: '#778899',
- lightslategrey: '#778899',
- lightsteelblue: '#B0C4DE',
- lightyellow: '#FFFFE0',
- limegreen: '#32CD32',
- linen: '#FAF0E6',
- mediumaquamarine: '#66CDAA',
- mediumblue: '#0000CD',
- mediumorchid: '#BA55D3',
- mediumpurple: '#9370DB',
- mediumseagreen: '#3CB371',
- mediumslateblue: '#7B68EE',
- mediumspringgreen: '#00FA9A',
- mediumturquoise: '#48D1CC',
- mediumvioletred: '#C71585',
- midnightblue: '#191970',
- mintcream: '#F5FFFA',
- mistyrose: '#FFE4E1',
- moccasin: '#FFE4B5',
- navajowhite: '#FFDEAD',
- oldlace: '#FDF5E6',
- olivedrab: '#6B8E23',
- orangered: '#FF4500',
- orchid: '#DA70D6',
- palegoldenrod: '#EEE8AA',
- palegreen: '#98FB98',
- paleturquoise: '#AFEEEE',
- palevioletred: '#DB7093',
- papayawhip: '#FFEFD5',
- peachpuff: '#FFDAB9',
- peru: '#CD853F',
- pink: '#FFC0CB',
- plum: '#DDA0DD',
- powderblue: '#B0E0E6',
- rosybrown: '#BC8F8F',
- royalblue: '#4169E1',
- saddlebrown: '#8B4513',
- salmon: '#FA8072',
- sandybrown: '#F4A460',
- seagreen: '#2E8B57',
- seashell: '#FFF5EE',
- sienna: '#A0522D',
- skyblue: '#87CEEB',
- slateblue: '#6A5ACD',
- slategray: '#708090',
- slategrey: '#708090',
- snow: '#FFFAFA',
- springgreen: '#00FF7F',
- steelblue: '#4682B4',
- tan: '#D2B48C',
- thistle: '#D8BFD8',
- tomato: '#FF6347',
- turquoise: '#40E0D0',
- violet: '#EE82EE',
- wheat: '#F5DEB3',
- whitesmoke: '#F5F5F5',
- yellowgreen: '#9ACD32',
- rebeccapurple: '#663399'
- }.freeze
-
def convert_color_name_to_hex
- color = params[:color]
- color_name = color.strip.downcase
-
- return color if color_name.start_with?('#')
-
- COLOR_NAME_TO_HEX[color_name.to_sym] || color
+ ::Gitlab::Color.of(params[:color])
end
end
end
diff --git a/app/services/loose_foreign_keys/batch_cleaner_service.rb b/app/services/loose_foreign_keys/batch_cleaner_service.rb
index f3db2037911..b89de15a568 100644
--- a/app/services/loose_foreign_keys/batch_cleaner_service.rb
+++ b/app/services/loose_foreign_keys/batch_cleaner_service.rb
@@ -54,7 +54,7 @@ module LooseForeignKeys
attr_reader :parent_table, :loose_foreign_key_definitions, :deleted_parent_records, :modification_tracker, :deleted_records_counter, :deleted_records_rescheduled_count, :deleted_records_incremented_count
def handle_over_limit
- return if Feature.disabled?(:lfk_fair_queueing)
+ return if Feature.disabled?(:lfk_fair_queueing, default_enabled: :yaml)
records_to_reschedule = []
records_to_increment = []
diff --git a/app/services/members/projects/creator_service.rb b/app/services/members/projects/creator_service.rb
index 2e974177075..4dba81acf73 100644
--- a/app/services/members/projects/creator_service.rb
+++ b/app/services/members/projects/creator_service.rb
@@ -4,7 +4,7 @@ module Members
module Projects
class CreatorService < Members::CreatorService
def self.access_levels
- Gitlab::Access.sym_options
+ Gitlab::Access.sym_options_with_owner
end
private
diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb
index 3f39b2742c6..37c2676e51c 100644
--- a/app/services/merge_requests/approval_service.rb
+++ b/app/services/merge_requests/approval_service.rb
@@ -11,10 +11,11 @@ module MergeRequests
reset_approvals_cache(merge_request)
create_event(merge_request)
+ stream_audit_event(merge_request)
create_approval_note(merge_request)
mark_pending_todos_as_done(merge_request)
execute_approval_hooks(merge_request, current_user)
- remove_attention_requested(merge_request, current_user)
+ remove_attention_requested(merge_request)
merge_request_activity_counter.track_approve_mr_action(user: current_user)
success
@@ -52,6 +53,10 @@ module MergeRequests
def create_event(merge_request)
event_service.approve_mr(merge_request, current_user)
end
+
+ def stream_audit_event(merge_request)
+ # Defined in EE
+ end
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 3363fc90997..2ab623bacf8 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -60,7 +60,9 @@ module MergeRequests
merge_request_activity_counter.track_reviewers_changed_action(user: current_user)
unless new_reviewers.include?(current_user)
- remove_attention_requested(merge_request, current_user)
+ remove_attention_requested(merge_request)
+
+ merge_request.merge_request_reviewers_with(new_reviewers).update_all(updated_state_by_user_id: current_user.id)
end
end
@@ -251,10 +253,10 @@ module MergeRequests
::MergeRequests::BulkRemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, users: users.uniq).execute
end
- def remove_attention_requested(merge_request, user)
+ def remove_attention_requested(merge_request)
return unless merge_request.attention_requested_enabled?
- ::MergeRequests::RemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, user: user).execute
+ ::MergeRequests::RemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request).execute
end
end
end
diff --git a/app/services/merge_requests/bulk_remove_attention_requested_service.rb b/app/services/merge_requests/bulk_remove_attention_requested_service.rb
index 6573b623779..774f2c2ee35 100644
--- a/app/services/merge_requests/bulk_remove_attention_requested_service.rb
+++ b/app/services/merge_requests/bulk_remove_attention_requested_service.rb
@@ -19,6 +19,8 @@ module MergeRequests
merge_request.merge_request_assignees.where(user_id: users).update_all(state: :reviewed)
merge_request.merge_request_reviewers.where(user_id: users).update_all(state: :reviewed)
+ users.each { |user| user.invalidate_attention_requested_count }
+
success
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index c1292d924b2..9c525ae8489 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -31,6 +31,14 @@ module MergeRequests
private
+ def before_create(merge_request)
+ # If the fetching of the source branch occurs in an ActiveRecord
+ # callback (e.g. after_create), a database transaction will be
+ # open while the Gitaly RPC waits. To avoid an idle in transaction
+ # timeout, we do this before we attempt to save the merge request.
+ merge_request.eager_fetch_ref!
+ end
+
def set_projects!
# @project is used to determine whether the user can set the merge request's
# assignee, milestone and labels. Whether they can depends on their
diff --git a/app/services/merge_requests/export_csv_service.rb b/app/services/merge_requests/export_csv_service.rb
index 8f2a70575e5..1f8dec69ef0 100644
--- a/app/services/merge_requests/export_csv_service.rb
+++ b/app/services/merge_requests/export_csv_service.rb
@@ -13,11 +13,11 @@ module MergeRequests
def header_to_value_hash
{
+ 'Title' => 'title',
+ 'Description' => 'description',
'MR IID' => 'iid',
'URL' => -> (merge_request) { merge_request_url(merge_request) },
- 'Title' => 'title',
'State' => 'state',
- 'Description' => 'description',
'Source Branch' => 'source_branch',
'Target Branch' => 'target_branch',
'Source Project ID' => 'source_project_id',
diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb
index 97be9fe8d9f..a169a6dc0b6 100644
--- a/app/services/merge_requests/handle_assignees_change_service.rb
+++ b/app/services/merge_requests/handle_assignees_change_service.rb
@@ -21,10 +21,12 @@ module MergeRequests
merge_request_activity_counter.track_users_assigned_to_mr(users: new_assignees)
merge_request_activity_counter.track_assignees_changed_action(user: current_user)
+ merge_request.merge_request_assignees_with(new_assignees).update_all(updated_state_by_user_id: current_user.id)
+
execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks]
unless new_assignees.include?(current_user)
- remove_attention_requested(merge_request, current_user)
+ remove_attention_requested(merge_request)
end
end
diff --git a/app/services/merge_requests/merge_orchestration_service.rb b/app/services/merge_requests/merge_orchestration_service.rb
index 24341ef1145..5f3d2174840 100644
--- a/app/services/merge_requests/merge_orchestration_service.rb
+++ b/app/services/merge_requests/merge_orchestration_service.rb
@@ -26,7 +26,7 @@ module MergeRequests
def can_merge_immediately?(merge_request)
merge_request.can_be_merged_by?(current_user) &&
- merge_request.mergeable_state?
+ merge_request.mergeable?
end
def can_merge_automatically?(merge_request)
diff --git a/app/services/merge_requests/mergeability/check_broken_status_service.rb b/app/services/merge_requests/mergeability/check_broken_status_service.rb
new file mode 100644
index 00000000000..9a54a4292c8
--- /dev/null
+++ b/app/services/merge_requests/mergeability/check_broken_status_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+module MergeRequests
+ module Mergeability
+ class CheckBrokenStatusService < CheckBaseService
+ def execute
+ if merge_request.broken?
+ failure
+ else
+ success
+ end
+ end
+
+ def skip?
+ false
+ end
+
+ def cacheable?
+ false
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/mergeability/check_discussions_status_service.rb b/app/services/merge_requests/mergeability/check_discussions_status_service.rb
new file mode 100644
index 00000000000..9b4eab9d399
--- /dev/null
+++ b/app/services/merge_requests/mergeability/check_discussions_status_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+module MergeRequests
+ module Mergeability
+ class CheckDiscussionsStatusService < CheckBaseService
+ def execute
+ if merge_request.mergeable_discussions_state?
+ success
+ else
+ failure
+ end
+ end
+
+ def skip?
+ params[:skip_discussions_check].present?
+ end
+
+ def cacheable?
+ false
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/mergeability/check_draft_status_service.rb b/app/services/merge_requests/mergeability/check_draft_status_service.rb
new file mode 100644
index 00000000000..bc940e2116d
--- /dev/null
+++ b/app/services/merge_requests/mergeability/check_draft_status_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ module Mergeability
+ class CheckDraftStatusService < CheckBaseService
+ def execute
+ if merge_request.draft?
+ failure
+ else
+ success
+ end
+ end
+
+ def skip?
+ false
+ end
+
+ def cacheable?
+ false
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/mergeability/check_open_status_service.rb b/app/services/merge_requests/mergeability/check_open_status_service.rb
new file mode 100644
index 00000000000..361af946e3f
--- /dev/null
+++ b/app/services/merge_requests/mergeability/check_open_status_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ module Mergeability
+ class CheckOpenStatusService < CheckBaseService
+ def execute
+ if merge_request.open?
+ success
+ else
+ failure
+ end
+ end
+
+ def skip?
+ false
+ end
+
+ def cacheable?
+ false
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/mergeability/run_checks_service.rb b/app/services/merge_requests/mergeability/run_checks_service.rb
index c1d65fb65cc..03c6d985c23 100644
--- a/app/services/merge_requests/mergeability/run_checks_service.rb
+++ b/app/services/merge_requests/mergeability/run_checks_service.rb
@@ -7,6 +7,10 @@ module MergeRequests
# We want to have the cheapest checks first in the list,
# that way we can fail fast before running the more expensive ones
CHECKS = [
+ CheckOpenStatusService,
+ CheckDraftStatusService,
+ CheckBrokenStatusService,
+ CheckDiscussionsStatusService,
CheckCiStatusService
].freeze
diff --git a/app/services/merge_requests/reload_merge_head_diff_service.rb b/app/services/merge_requests/reload_merge_head_diff_service.rb
index f02a9bd3139..4724dd1c068 100644
--- a/app/services/merge_requests/reload_merge_head_diff_service.rb
+++ b/app/services/merge_requests/reload_merge_head_diff_service.rb
@@ -9,7 +9,6 @@ module MergeRequests
end
def execute
- return error("default_merge_ref_for_diffs feature flag is disabled") unless enabled?
return error("Merge request has no merge ref head.") unless merge_request.merge_ref_head.present?
error_msg = recreate_merge_head_diff
@@ -23,10 +22,6 @@ module MergeRequests
attr_reader :merge_request
- def enabled?
- Feature.enabled?(:default_merge_ref_for_diffs, merge_request.project, default_enabled: :yaml)
- end
-
def recreate_merge_head_diff
merge_request.merge_head_diff&.destroy!
diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb
index 198a21884b8..c7bc3532264 100644
--- a/app/services/merge_requests/remove_approval_service.rb
+++ b/app/services/merge_requests/remove_approval_service.rb
@@ -17,7 +17,7 @@ module MergeRequests
reset_approvals_cache(merge_request)
create_note(merge_request)
merge_request_activity_counter.track_unapprove_mr_action(user: current_user)
- remove_attention_requested(merge_request, current_user)
+ remove_attention_requested(merge_request)
end
success
diff --git a/app/services/merge_requests/remove_attention_requested_service.rb b/app/services/merge_requests/remove_attention_requested_service.rb
index b727c24415e..a32a8071471 100644
--- a/app/services/merge_requests/remove_attention_requested_service.rb
+++ b/app/services/merge_requests/remove_attention_requested_service.rb
@@ -2,13 +2,12 @@
module MergeRequests
class RemoveAttentionRequestedService < MergeRequests::BaseService
- attr_accessor :merge_request, :user
+ attr_accessor :merge_request
- def initialize(project:, current_user:, merge_request:, user:)
+ def initialize(project:, current_user:, merge_request:)
super(project: project, current_user: current_user)
@merge_request = merge_request
- @user = user
end
def execute
@@ -18,6 +17,8 @@ module MergeRequests
update_state(reviewer)
update_state(assignee)
+ current_user.invalidate_attention_requested_count
+
success
else
error("User is not a reviewer or assignee of the merge request")
@@ -27,11 +28,11 @@ module MergeRequests
private
def assignee
- merge_request.find_assignee(user)
+ merge_request.find_assignee(current_user)
end
def reviewer
- merge_request.find_reviewer(user)
+ merge_request.find_reviewer(current_user)
end
def update_state(reviewer_or_assignee)
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index 35c50d63da0..4612688f78b 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -6,6 +6,8 @@ module MergeRequests
return merge_request unless can?(current_user, :reopen_merge_request, merge_request)
if merge_request.reopen
+ users = merge_request.assignees | merge_request.reviewers
+
create_event(merge_request)
create_note(merge_request, 'reopened')
merge_request_activity_counter.track_reopen_mr_action(user: current_user)
@@ -13,11 +15,13 @@ module MergeRequests
execute_hooks(merge_request, 'reopen')
merge_request.reload_diff(current_user)
merge_request.mark_as_unchecked
- invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers)
+ invalidate_cache_counts(merge_request, users: users)
merge_request.update_project_counter_caches
merge_request.cache_merge_request_closes_issues!(current_user)
merge_request.cleanup_schedule&.destroy
merge_request.update_column(:merge_ref_sha, nil)
+
+ users.each { |user| user.invalidate_attention_requested_count }
end
merge_request
diff --git a/app/services/merge_requests/toggle_attention_requested_service.rb b/app/services/merge_requests/toggle_attention_requested_service.rb
index d9f81ac310f..64cdcd725a2 100644
--- a/app/services/merge_requests/toggle_attention_requested_service.rb
+++ b/app/services/merge_requests/toggle_attention_requested_service.rb
@@ -18,12 +18,14 @@ module MergeRequests
update_state(reviewer)
update_state(assignee)
+ user.invalidate_attention_requested_count
+
if reviewer&.attention_requested? || assignee&.attention_requested?
create_attention_request_note
notity_user
if current_user.id != user.id
- remove_attention_requested(merge_request, current_user)
+ remove_attention_requested(merge_request)
end
else
create_remove_attention_request_note
@@ -59,7 +61,8 @@ module MergeRequests
end
def update_state(reviewer_or_assignee)
- reviewer_or_assignee&.update(state: reviewer_or_assignee&.attention_requested? ? :reviewed : :attention_requested)
+ reviewer_or_assignee&.update(state: reviewer_or_assignee&.attention_requested? ? :reviewed : :attention_requested,
+ updated_state_by: current_user)
end
end
end
diff --git a/app/services/notification_recipients/builder/merge_request_unmergeable.rb b/app/services/notification_recipients/builder/merge_request_unmergeable.rb
index 24d96b98002..b9facf07a3a 100644
--- a/app/services/notification_recipients/builder/merge_request_unmergeable.rb
+++ b/app/services/notification_recipients/builder/merge_request_unmergeable.rb
@@ -4,6 +4,7 @@ module NotificationRecipients
module Builder
class MergeRequestUnmergeable < Base
attr_reader :target
+
def initialize(merge_request)
@target = merge_request
end
diff --git a/app/services/notification_recipients/builder/new_note.rb b/app/services/notification_recipients/builder/new_note.rb
index 17e4728d352..dcf6d23298a 100644
--- a/app/services/notification_recipients/builder/new_note.rb
+++ b/app/services/notification_recipients/builder/new_note.rb
@@ -4,6 +4,7 @@ module NotificationRecipients
module Builder
class NewNote < Base
attr_reader :note
+
def initialize(note)
@note = note
end
diff --git a/app/services/notification_recipients/builder/new_review.rb b/app/services/notification_recipients/builder/new_review.rb
index 3b1296f6967..84598c3d4ad 100644
--- a/app/services/notification_recipients/builder/new_review.rb
+++ b/app/services/notification_recipients/builder/new_review.rb
@@ -4,6 +4,7 @@ module NotificationRecipients
module Builder
class NewReview < Base
attr_reader :review
+
def initialize(review)
@review = review
end
diff --git a/app/services/notification_recipients/builder/project_maintainers.rb b/app/services/notification_recipients/builder/project_maintainers.rb
index e8f22c00a83..a295929a1a9 100644
--- a/app/services/notification_recipients/builder/project_maintainers.rb
+++ b/app/services/notification_recipients/builder/project_maintainers.rb
@@ -14,6 +14,7 @@ module NotificationRecipients
return [] unless project
add_recipients(project.team.maintainers, :mention, nil)
+ add_recipients(project.team.owners, :mention, nil)
end
def acting_user
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 5b1733422d0..aa7e636b8a4 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -18,6 +18,7 @@
class NotificationService
class Async
attr_reader :parent
+
delegate :respond_to_missing, to: :parent
def initialize(parent)
@@ -64,6 +65,13 @@ class NotificationService
end
end
+ # Notify the owner of the account when a new personal access token is created
+ def access_token_created(user, token_name)
+ return unless user.can?(:receive_notifications)
+
+ mailer.access_token_created_email(user, token_name).deliver_later
+ end
+
# Notify the owner of the personal access token, when it is about to expire
# And mark the token with about_to_expire_delivered
def access_token_about_to_expire(user, token_names)
diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb
index b988c191734..5d7e967ceb0 100644
--- a/app/services/packages/pypi/create_package_service.rb
+++ b/app/services/packages/pypi/create_package_service.rb
@@ -9,7 +9,7 @@ module Packages
::Packages::Package.transaction do
meta = Packages::Pypi::Metadatum.new(
package: created_package,
- required_python: params[:requires_python]
+ required_python: params[:requires_python] || ''
)
unless meta.valid?
diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb
index 7555ba26768..e2f2e220750 100644
--- a/app/services/personal_access_tokens/create_service.rb
+++ b/app/services/personal_access_tokens/create_service.rb
@@ -16,6 +16,7 @@ module PersonalAccessTokens
if token.persisted?
log_event(token)
+ notification_service.access_token_created(target_user, token.name)
ServiceResponse.success(payload: { personal_access_token: token })
else
ServiceResponse.error(message: token.errors.full_messages.to_sentence, payload: { personal_access_token: token })
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
index f5638b0aa40..15c978e6763 100644
--- a/app/services/post_receive_service.rb
+++ b/app/services/post_receive_service.rb
@@ -86,7 +86,7 @@ class PostReceiveService
banner = nil
if project
- scoped_messages = BroadcastMessage.current_banner_messages(project.full_path).select do |message|
+ scoped_messages = BroadcastMessage.current_banner_messages(current_path: project.full_path).select do |message|
message.target_path.present? && message.matches_current_path(project.full_path)
end
diff --git a/app/services/projects/base_move_relations_service.rb b/app/services/projects/base_move_relations_service.rb
index 3a159cef58b..bd5a39d3b59 100644
--- a/app/services/projects/base_move_relations_service.rb
+++ b/app/services/projects/base_move_relations_service.rb
@@ -3,6 +3,7 @@
module Projects
class BaseMoveRelationsService < BaseService
attr_reader :source_project
+
def execute(source_project, remove_remaining_elements: true)
return if source_project.blank?
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
index 1a788abac12..72f3fddb4c3 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -145,12 +145,14 @@ module Projects
end
def caching_enabled?
- container_expiration_policy &&
- older_than.present?
+ result = ::Gitlab::CurrentSettings.current_application_settings.container_registry_expiration_policies_caching &&
+ container_expiration_policy &&
+ older_than.present?
+ !!result
end
def throttling_enabled?
- Feature.enabled?(:container_registry_expiration_policies_throttling)
+ Feature.enabled?(:container_registry_expiration_policies_throttling, default_enabled: :yaml)
end
def max_list_size
diff --git a/app/services/projects/container_repository/gitlab/delete_tags_service.rb b/app/services/projects/container_repository/gitlab/delete_tags_service.rb
index 589aac5c3ac..f109cb0ca20 100644
--- a/app/services/projects/container_repository/gitlab/delete_tags_service.rb
+++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb
@@ -54,7 +54,7 @@ module Projects
def throttling_enabled?
strong_memoize(:feature_flag) do
- Feature.enabled?(:container_registry_expiration_policies_throttling)
+ Feature.enabled?(:container_registry_expiration_policies_throttling, default_enabled: :yaml)
end
end
diff --git a/app/services/projects/container_repository/third_party/delete_tags_service.rb b/app/services/projects/container_repository/third_party/delete_tags_service.rb
index 404642acf72..4184c676fc3 100644
--- a/app/services/projects/container_repository/third_party/delete_tags_service.rb
+++ b/app/services/projects/container_repository/third_party/delete_tags_service.rb
@@ -41,14 +41,12 @@ module Projects
# update the manifests of the tags with the new dummy image
def replace_tag_manifests(dummy_manifest)
- deleted_tags = {}
-
- @tag_names.each do |name|
+ deleted_tags = @tag_names.map do |name|
digest = @container_repository.client.put_tag(@container_repository.path, name, dummy_manifest)
next unless digest
- deleted_tags[name] = digest
- end
+ [name, digest]
+ end.compact.to_h
# make sure the digests are the same (it should always be)
digests = deleted_tags.values.uniq
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index c885369dfec..252e1d76bef 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -147,7 +147,7 @@ module Projects
priority: UserProjectAccessChangedService::LOW_PRIORITY
)
else
- @project.add_maintainer(@project.namespace.owner, current_user: current_user)
+ @project.add_owner(@project.namespace.owner, current_user: current_user)
end
end
diff --git a/app/services/projects/deploy_tokens/create_service.rb b/app/services/projects/deploy_tokens/create_service.rb
index 592198ef241..2486544b150 100644
--- a/app/services/projects/deploy_tokens/create_service.rb
+++ b/app/services/projects/deploy_tokens/create_service.rb
@@ -13,3 +13,5 @@ module Projects
end
end
end
+
+Projects::DeployTokens::CreateService.prepend_mod
diff --git a/app/services/projects/deploy_tokens/destroy_service.rb b/app/services/projects/deploy_tokens/destroy_service.rb
index e063f86a65c..7ac1b52b0af 100644
--- a/app/services/projects/deploy_tokens/destroy_service.rb
+++ b/app/services/projects/deploy_tokens/destroy_service.rb
@@ -11,3 +11,5 @@ module Projects
end
end
end
+
+Projects::DeployTokens::DestroyService.prepend_mod
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 95af5a6863f..a73244c6971 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -37,7 +37,7 @@ module Projects
system_hook_service.execute_hooks_for(project, :destroy)
log_info("Project \"#{project.full_path}\" was deleted")
- publish_project_deleted_event_for(project) if Feature.enabled?(:publish_project_deleted_event, default_enabled: :yaml)
+ publish_project_deleted_event_for(project)
current_user.invalidate_personal_projects_count
@@ -72,7 +72,13 @@ module Projects
end
def remove_snippets
- response = ::Snippets::BulkDestroyService.new(current_user, project.snippets).execute
+ # We're setting the hard_delete param because we dont need to perform the access checks within the service since
+ # the user has enough access rights to remove the project and its resources.
+ response = ::Snippets::BulkDestroyService.new(current_user, project.snippets).execute(hard_delete: true)
+
+ if response.error?
+ log_error("Snippet deletion failed on #{project.full_path} with the following message: #{response.message}")
+ end
response.success?
end
@@ -194,6 +200,10 @@ module Projects
::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline)
end
+ project.secure_files.find_each(batch_size: BATCH_SIZE) do |secure_file| # rubocop: disable CodeReuse/ActiveRecord
+ ::Ci::DestroySecureFileService.new(project, current_user).execute(secure_file)
+ end
+
deleted_count = ::CommitStatus.for_project(project).delete_all
Gitlab::AppLogger.info(
diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb
index 9da72d9300e..76005a1c96e 100644
--- a/app/services/projects/lfs_pointers/lfs_download_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_service.rb
@@ -11,6 +11,7 @@ module Projects
LARGE_FILE_SIZE = 1.megabytes
attr_reader :lfs_download_object
+
delegate :oid, :size, :credentials, :sanitized_url, :headers, to: :lfs_download_object, prefix: :lfs
def initialize(project, lfs_download_object)
diff --git a/app/services/projects/refresh_build_artifacts_size_statistics_service.rb b/app/services/projects/refresh_build_artifacts_size_statistics_service.rb
new file mode 100644
index 00000000000..794c042ea39
--- /dev/null
+++ b/app/services/projects/refresh_build_artifacts_size_statistics_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Projects
+ class RefreshBuildArtifactsSizeStatisticsService
+ BATCH_SIZE = 1000
+
+ def execute
+ refresh = Projects::BuildArtifactsSizeRefresh.process_next_refresh!
+ return unless refresh
+
+ batch = refresh.next_batch(limit: BATCH_SIZE).to_a
+
+ if batch.any?
+ # We are doing the sum in ruby because the query takes too long when done in SQL
+ total_artifacts_size = batch.sum(&:size)
+
+ Projects::BuildArtifactsSizeRefresh.transaction do
+ # Mark the refresh ready for another worker to pick up and process the next batch
+ refresh.requeue!(batch.last.id)
+
+ refresh.project.statistics.delayed_increment_counter(:build_artifacts_size, total_artifacts_size)
+ end
+ else
+ # Remove the refresh job from the table if there are no more
+ # remaining job artifacts to calculate for the given project.
+ refresh.destroy!
+ end
+
+ refresh
+ end
+ end
+end
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 0000e713cb4..2ec965fe2f4 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -22,8 +22,8 @@ module Projects
register_attempt
# Create status notifying the deployment of pages
- @status = build_commit_status
- ::Ci::Pipelines::AddJobService.new(@build.pipeline).execute!(@status) do |job|
+ @commit_status = build_commit_status
+ ::Ci::Pipelines::AddJobService.new(@build.pipeline).execute!(@commit_status) do |job|
job.enqueue!
job.run!
end
@@ -46,17 +46,17 @@ module Projects
private
def success
- @status.success
- @project.mark_pages_as_deployed(artifacts_archive: build.job_artifacts_archive)
+ @commit_status.success
+ @project.mark_pages_as_deployed
super
end
def error(message)
register_failure
log_error("Projects::UpdatePagesService: #{message}")
- @status.allow_failure = !latest?
- @status.description = message
- @status.drop(:script_failure)
+ @commit_status.allow_failure = !latest?
+ @commit_status.description = message
+ @commit_status.drop(:script_failure)
super
end
diff --git a/app/services/repositories/base_service.rb b/app/services/repositories/base_service.rb
index efb6f6de8db..13ad126f8f0 100644
--- a/app/services/repositories/base_service.rb
+++ b/app/services/repositories/base_service.rb
@@ -18,8 +18,6 @@ class Repositories::BaseService < BaseService
end
def mv_repository(from_path, to_path)
- return true unless repo_exists?(from_path)
-
gitlab_shell.mv_repository(repository.shard, from_path, to_path)
end
diff --git a/app/services/repositories/destroy_rollback_service.rb b/app/services/repositories/destroy_rollback_service.rb
index 5ef4e11bf55..a19e305607f 100644
--- a/app/services/repositories/destroy_rollback_service.rb
+++ b/app/services/repositories/destroy_rollback_service.rb
@@ -12,8 +12,14 @@ class Repositories::DestroyRollbackService < Repositories::BaseService
log_info(%Q{Repository "#{removal_path}" moved to "#{disk_path}" for repository "#{full_path}"})
success
- else
+ elsif repo_exists?(removal_path)
+ # If the repo does not exist, there is no need to return an
+ # error because there was nothing to do.
move_error(removal_path)
+ else
+ success
end
+ rescue Gitlab::Git::Repository::NoRepository
+ success
end
end
diff --git a/app/services/repositories/destroy_service.rb b/app/services/repositories/destroy_service.rb
index 1e34dfbe398..c5a0af56066 100644
--- a/app/services/repositories/destroy_service.rb
+++ b/app/services/repositories/destroy_service.rb
@@ -30,8 +30,12 @@ class Repositories::DestroyService < Repositories::BaseService
log_info("Repository \"#{full_path}\" was removed")
success
- else
+ elsif repo_exists?(disk_path)
move_error(disk_path)
+ else
+ success
end
+ rescue Gitlab::Git::Repository::NoRepository
+ success
end
end
diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb
index ea77cd98ba3..7f3b66d40e1 100644
--- a/app/services/security/ci_configuration/base_create_service.rb
+++ b/app/services/security/ci_configuration/base_create_service.rb
@@ -41,7 +41,7 @@ module Security
end
def existing_gitlab_ci_content
- @gitlab_ci_yml ||= project.repository.gitlab_ci_yml_for(project.repository.root_ref_sha)
+ @gitlab_ci_yml ||= project.ci_config_for(project.repository.root_ref_sha)
YAML.safe_load(@gitlab_ci_yml) if @gitlab_ci_yml
end
diff --git a/app/services/security/ci_configuration/container_scanning_create_service.rb b/app/services/security/ci_configuration/container_scanning_create_service.rb
index 788533575e6..da2f1ac0981 100644
--- a/app/services/security/ci_configuration/container_scanning_create_service.rb
+++ b/app/services/security/ci_configuration/container_scanning_create_service.rb
@@ -6,7 +6,8 @@ module Security
private
def action
- Security::CiConfiguration::ContainerScanningBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate
+ Security::CiConfiguration::ContainerScanningBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content,
+ project.ci_config_path).generate
end
def next_branch
diff --git a/app/services/security/ci_configuration/dependency_scanning_create_service.rb b/app/services/security/ci_configuration/dependency_scanning_create_service.rb
index 71e8d5025ae..b11eccc680c 100644
--- a/app/services/security/ci_configuration/dependency_scanning_create_service.rb
+++ b/app/services/security/ci_configuration/dependency_scanning_create_service.rb
@@ -6,7 +6,8 @@ module Security
private
def action
- Security::CiConfiguration::DependencyScanningBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate
+ Security::CiConfiguration::DependencyScanningBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content,
+ project.ci_config_path).generate
end
def next_branch
diff --git a/app/services/security/ci_configuration/sast_create_service.rb b/app/services/security/ci_configuration/sast_create_service.rb
index 47e01847b17..d78e22f1fe1 100644
--- a/app/services/security/ci_configuration/sast_create_service.rb
+++ b/app/services/security/ci_configuration/sast_create_service.rb
@@ -26,7 +26,7 @@ module Security
nil
end
- Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_content).generate
+ Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_content, project.ci_config_path).generate
end
def next_branch
diff --git a/app/services/security/ci_configuration/sast_iac_create_service.rb b/app/services/security/ci_configuration/sast_iac_create_service.rb
index 80e9cf963da..fbc65484216 100644
--- a/app/services/security/ci_configuration/sast_iac_create_service.rb
+++ b/app/services/security/ci_configuration/sast_iac_create_service.rb
@@ -6,7 +6,8 @@ module Security
private
def action
- Security::CiConfiguration::SastIacBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate
+ Security::CiConfiguration::SastIacBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content,
+ project.ci_config_path).generate
end
def next_branch
diff --git a/app/services/security/ci_configuration/secret_detection_create_service.rb b/app/services/security/ci_configuration/secret_detection_create_service.rb
index ff3458d36fc..ca5138b6ed6 100644
--- a/app/services/security/ci_configuration/secret_detection_create_service.rb
+++ b/app/services/security/ci_configuration/secret_detection_create_service.rb
@@ -6,7 +6,8 @@ module Security
private
def action
- Security::CiConfiguration::SecretDetectionBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate
+ Security::CiConfiguration::SecretDetectionBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content,
+ project.ci_config_path).generate
end
def next_branch
diff --git a/app/services/security/merge_reports_service.rb b/app/services/security/merge_reports_service.rb
index 5f6f98a3c39..a982ec7efe2 100644
--- a/app/services/security/merge_reports_service.rb
+++ b/app/services/security/merge_reports_service.rb
@@ -21,7 +21,10 @@ module Security
source_reports.first.type,
source_reports.first.pipeline,
source_reports.first.created_at
- ).tap { |report| report.errors = source_reports.flat_map(&:errors) }
+ ).tap do |report|
+ report.errors = source_reports.flat_map(&:errors)
+ report.warnings = source_reports.flat_map(&:warnings)
+ end
end
def copy_resources_to_target_report
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index 2a28b66f09b..4fa9c0e4993 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -65,22 +65,19 @@ module Spam
# ask the SpamVerdictService what to do with the target.
spam_verdict_service.execute.tap do |result|
case result
- when CONDITIONAL_ALLOW
- # at the moment, this means "ask for reCAPTCHA"
- create_spam_log
-
- break if target.allow_possible_spam?
-
- target.needs_recaptcha!
- when DISALLOW
- # TODO: remove `unless target.allow_possible_spam?` once this flag has been passed to `SpamVerdictService`
- # https://gitlab.com/gitlab-org/gitlab/-/issues/214739
- target.spam! unless target.allow_possible_spam?
- create_spam_log
when BLOCK_USER
# TODO: improve BLOCK_USER handling, non-existent until now
# https://gitlab.com/gitlab-org/gitlab/-/issues/329666
- target.spam! unless target.allow_possible_spam?
+ target.spam!
+ create_spam_log
+ when DISALLOW
+ target.spam!
+ create_spam_log
+ when CONDITIONAL_ALLOW
+ # This means "require a CAPTCHA to be solved"
+ target.needs_recaptcha!
+ create_spam_log
+ when OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM
create_spam_log
when ALLOW
target.clear_spam_flags!
diff --git a/app/services/spam/spam_constants.rb b/app/services/spam/spam_constants.rb
index b654fbbbcc8..d300525710c 100644
--- a/app/services/spam/spam_constants.rb
+++ b/app/services/spam/spam_constants.rb
@@ -2,11 +2,12 @@
module Spam
module SpamConstants
- CONDITIONAL_ALLOW = "conditional_allow"
- DISALLOW = "disallow"
- ALLOW = "allow"
- BLOCK_USER = "block"
- NOOP = "noop"
+ BLOCK_USER = 'block'
+ DISALLOW = 'disallow'
+ CONDITIONAL_ALLOW = 'conditional_allow'
+ OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM = 'override_via_allow_possible_spam'
+ ALLOW = 'allow'
+ NOOP = 'noop'
SUPPORTED_VERDICTS = {
BLOCK_USER => {
@@ -18,11 +19,14 @@ module Spam
CONDITIONAL_ALLOW => {
priority: 3
},
- ALLOW => {
+ OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM => {
priority: 4
},
- NOOP => {
+ ALLOW => {
priority: 5
+ },
+ NOOP => {
+ priority: 6
}
}.freeze
end
diff --git a/app/services/spam/spam_params.rb b/app/services/spam/spam_params.rb
index ccc17a42f01..81db6b390b2 100644
--- a/app/services/spam/spam_params.rb
+++ b/app/services/spam/spam_params.rb
@@ -25,6 +25,7 @@ module Spam
# then the spam check may fail, or the SpamLog or UserAgentDetail may have missing fields.
class SpamParams
def self.new_from_request(request:)
+ self.normalize_grape_request_headers(request: request)
self.new(
captcha_response: request.headers['X-GitLab-Captcha-Response'],
spam_log_id: request.headers['X-GitLab-Spam-Log-Id'],
@@ -52,5 +53,14 @@ module Spam
other.user_agent == user_agent &&
other.referer == referer
end
+
+ def self.normalize_grape_request_headers(request:)
+ # If needed, make a normalized copy of Grape headers with the case of 'GitLab' (with an
+ # uppercase 'L') instead of 'Gitlab' (with a lowercase 'l'), because Grape header helper keys
+ # are "coerced into a capitalized kebab case". See https://github.com/ruby-grape/grape#request
+ %w[X-Gitlab-Captcha-Response X-Gitlab-Spam-Log-Id].each do |header|
+ request.headers[header.gsub('Gitlab', 'GitLab')] = request.headers[header] if request.headers.key?(header)
+ end
+ end
end
end
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index c8bdcf4310b..e73b2666c02 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -39,21 +39,24 @@ module Spam
return ALLOW unless valid_results.any?
# Favour the most restrictive result.
- final_verdict = valid_results.min_by { |v| SUPPORTED_VERDICTS[v][:priority] }
+ verdict = valid_results.min_by { |v| SUPPORTED_VERDICTS[v][:priority] }
+
+ # The target can override the verdict via the `allow_possible_spam` feature flag
+ verdict = OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM if override_via_allow_possible_spam?(verdict: verdict)
logger.info(class: self.class.name,
akismet_verdict: akismet_verdict,
spam_check_verdict: original_spamcheck_result,
extra_attributes: spamcheck_attribs,
spam_check_rtt: external_spam_check_round_trip_time.real,
- final_verdict: final_verdict,
+ final_verdict: verdict,
username: user.username,
user_id: user.id,
target_type: target.class.to_s,
project_id: target.project_id
)
- final_verdict
+ verdict
end
private
@@ -87,6 +90,14 @@ module Spam
end
end
+ def override_via_allow_possible_spam?(verdict:)
+ # If the verdict is already going to allow (because current verdict's priority value is greater
+ # than the override verdict's priority value), then we don't need to override it.
+ return false if SUPPORTED_VERDICTS[verdict][:priority] > SUPPORTED_VERDICTS[OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM][:priority]
+
+ target.allow_possible_spam?
+ end
+
def spamcheck_client
@spamcheck_client ||= Gitlab::Spamcheck::Client.new
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 1f1edad7a69..9db39a5e174 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -49,12 +49,12 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_contacts(added_count, removed_count)
end
- def relate_issue(noteable, noteable_ref, user)
- ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).relate_issue(noteable_ref)
+ def relate_issuable(noteable, noteable_ref, user)
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).relate_issuable(noteable_ref)
end
- def unrelate_issue(noteable, noteable_ref, user)
- ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).unrelate_issue(noteable_ref)
+ def unrelate_issuable(noteable, noteable_ref, user)
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).unrelate_issuable(noteable_ref)
end
# Called when the due_date of a Noteable is changed
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 09f36bb6501..89212288a6b 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -10,8 +10,9 @@ module SystemNotes
# "marked this issue as related to gitlab-foss#9001"
#
# Returns the created Note object
- def relate_issue(noteable_ref)
- body = "marked this issue as related to #{noteable_ref.to_reference(noteable.project)}"
+ def relate_issuable(noteable_ref)
+ issuable_type = noteable.to_ability_name.humanize(capitalize: false)
+ body = "marked this #{issuable_type} as related to #{noteable_ref.to_reference(noteable.resource_parent)}"
issue_activity_counter.track_issue_related_action(author: author) if noteable.is_a?(Issue)
@@ -26,8 +27,8 @@ module SystemNotes
# "removed the relation with gitlab-foss#9001"
#
# Returns the created Note object
- def unrelate_issue(noteable_ref)
- body = "removed the relation with #{noteable_ref.to_reference(noteable.project)}"
+ def unrelate_issuable(noteable_ref)
+ body = "removed the relation with #{noteable_ref.to_reference(noteable.resource_parent)}"
issue_activity_counter.track_issue_unrelated_action(author: author) if noteable.is_a?(Issue)
@@ -160,6 +161,7 @@ module SystemNotes
body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**"
issue_activity_counter.track_issue_title_changed_action(author: author) if noteable.is_a?(Issue)
+ work_item_activity_counter.track_work_item_title_changed_action(author: author) if noteable.is_a?(WorkItem)
create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end
@@ -484,6 +486,10 @@ module SystemNotes
Gitlab::UsageDataCounters::IssueActivityUniqueCounter
end
+ def work_item_activity_counter
+ Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter
+ end
+
def track_cross_reference_action
issue_activity_counter.track_issue_cross_referenced_action(author: author) if noteable.is_a?(Issue)
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 091f441831a..64309c7f786 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -9,6 +9,7 @@
#
class TodoService
include Gitlab::Utils::UsageData
+
# When create an issue we should:
#
# * create a todo for assignee if issue is assigned
@@ -229,8 +230,24 @@ class TodoService
return if users.empty?
- users_with_pending_todos = pending_todos(users, attributes).distinct_user_ids
- users.reject! { |user| users_with_pending_todos.include?(user.id) && Feature.disabled?(:multiple_todos, user) }
+ users_single_todos, users_multiple_todos = users.partition { |u| Feature.disabled?(:multiple_todos, u) }
+ excluded_user_ids = []
+
+ if users_single_todos.present?
+ excluded_user_ids += pending_todos(
+ users_single_todos,
+ attributes.slice(:project_id, :target_id, :target_type, :commit_id, :discussion)
+ ).distinct_user_ids
+ end
+
+ if users_multiple_todos.present? && !Todo::ACTIONS_MULTIPLE_ALLOWED.include?(attributes.fetch(:action))
+ excluded_user_ids += pending_todos(
+ users_multiple_todos,
+ attributes.slice(:project_id, :target_id, :target_type, :commit_id, :discussion, :action)
+ ).distinct_user_ids
+ end
+
+ users.reject! { |user| excluded_user_ids.include?(user.id) }
todos = users.map do |user|
issue_type = attributes.delete(:issue_type)
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index 575614e8743..604b83f621f 100644
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -66,20 +66,20 @@ module Users
# rubocop: disable CodeReuse/ActiveRecord
def migrate_issues
- user.issues.update_all(author_id: ghost_user.id)
- Issue.where(last_edited_by_id: user.id).update_all(last_edited_by_id: ghost_user.id)
+ batched_migrate(Issue, :author_id)
+ batched_migrate(Issue, :last_edited_by_id)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def migrate_merge_requests
- user.merge_requests.update_all(author_id: ghost_user.id)
- MergeRequest.where(merge_user_id: user.id).update_all(merge_user_id: ghost_user.id)
+ batched_migrate(MergeRequest, :author_id)
+ batched_migrate(MergeRequest, :merge_user_id)
end
# rubocop: enable CodeReuse/ActiveRecord
def migrate_notes
- user.notes.update_all(author_id: ghost_user.id)
+ batched_migrate(Note, :author_id)
end
def migrate_abuse_reports
@@ -96,8 +96,17 @@ module Users
end
def migrate_reviews
- user.reviews.update_all(author_id: ghost_user.id)
+ batched_migrate(Review, :author_id)
end
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ def batched_migrate(base_scope, column)
+ loop do
+ update_count = base_scope.where(column => user.id).limit(100).update_all(column => ghost_user.id)
+ break if update_count == 0
+ end
+ end
+ # rubocop:enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/users/saved_replies/create_service.rb b/app/services/users/saved_replies/create_service.rb
new file mode 100644
index 00000000000..21378ec4435
--- /dev/null
+++ b/app/services/users/saved_replies/create_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Users
+ module SavedReplies
+ class CreateService
+ def initialize(current_user:, name:, content:)
+ @current_user = current_user
+ @name = name
+ @content = content
+ end
+
+ def execute
+ saved_reply = saved_replies.build(name: name, content: content)
+
+ if saved_reply.save
+ ServiceResponse.success(payload: { saved_reply: saved_reply })
+ else
+ ServiceResponse.error(message: saved_reply.errors.full_messages)
+ end
+ end
+
+ private
+
+ attr_reader :current_user, :name, :content
+
+ delegate :saved_replies, to: :current_user
+ end
+ end
+end
diff --git a/app/services/users/saved_replies/update_service.rb b/app/services/users/saved_replies/update_service.rb
new file mode 100644
index 00000000000..ab0a3eaf87d
--- /dev/null
+++ b/app/services/users/saved_replies/update_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Users
+ module SavedReplies
+ class UpdateService
+ def initialize(current_user:, saved_reply:, name:, content:)
+ @current_user = current_user
+ @saved_reply = saved_reply
+ @name = name
+ @content = content
+ end
+
+ def execute
+ if saved_reply.update(name: name, content: content)
+ ServiceResponse.success(payload: { saved_reply: saved_reply.reset })
+ else
+ ServiceResponse.error(message: saved_reply.errors.full_messages)
+ end
+ end
+
+ private
+
+ attr_reader :current_user, :saved_reply, :name, :content
+ end
+ end
+end
diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb
index 6e58e15f093..0ee7c41469f 100644
--- a/app/services/web_hooks/log_execution_service.rb
+++ b/app/services/web_hooks/log_execution_service.rb
@@ -2,34 +2,86 @@
module WebHooks
class LogExecutionService
+ include ::Gitlab::ExclusiveLeaseHelpers
+
+ LOCK_TTL = 15.seconds.freeze
+ LOCK_SLEEP = 0.25.seconds.freeze
+ LOCK_RETRY = 65
+
attr_reader :hook, :log_data, :response_category
def initialize(hook:, log_data:, response_category:)
@hook = hook
- @log_data = log_data
+ @log_data = log_data.transform_keys(&:to_sym)
@response_category = response_category
+ @prev_state = hook.active_state(ignore_flag: true)
end
def execute
- update_hook_executability
+ update_hook_failure_state
log_execution
end
private
def log_execution
- WebHookLog.create!(web_hook: hook, **log_data.transform_keys(&:to_sym))
+ WebHookLog.create!(web_hook: hook, **log_data)
end
- def update_hook_executability
- case response_category
- when :ok
- hook.enable!
- when :error
- hook.backoff!
- when :failed
- hook.failed!
+ # Perform this operation within an `Gitlab::ExclusiveLease` lock to make it
+ # safe to be called concurrently from different workers.
+ def update_hook_failure_state
+ in_lock(lock_name, ttl: LOCK_TTL, sleep_sec: LOCK_SLEEP, retries: LOCK_RETRY) do |retried|
+ hook.reset # Reload within the lock so properties are guaranteed to be current.
+
+ case response_category
+ when :ok
+ hook.enable!
+ when :error
+ hook.backoff!
+ when :failed
+ hook.failed!
+ end
+
+ log_state_change
end
+ rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
+ raise if raise_lock_error?
+ end
+
+ def log_state_change
+ new_state = hook.active_state(ignore_flag: true)
+
+ return if @prev_state == new_state
+
+ Gitlab::AuthLogger.info(
+ message: 'WebHook change active_state',
+ # identification
+ hook_id: hook.id,
+ hook_type: hook.type,
+ project_id: hook.project_id,
+ group_id: hook.group_id,
+ # relevant data
+ prev_state: @prev_state,
+ new_state: new_state,
+ duration: log_data[:execution_duration],
+ response_status: log_data[:response_status],
+ recent_hook_failures: hook.recent_failures,
+ # context
+ **Gitlab::ApplicationContext.current
+ )
+ end
+
+ def lock_name
+ "web_hooks:update_hook_failure_state:#{hook.id}"
+ end
+
+ # Allow an error to be raised after failing to obtain a lease only if the hook
+ # is not already in the correct failure state.
+ def raise_lock_error?
+ hook.reset # Reload so properties are guaranteed to be current.
+
+ hook.executable? != (response_category == :ok)
end
end
end
diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb
new file mode 100644
index 00000000000..534d220a846
--- /dev/null
+++ b/app/services/work_items/create_and_link_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module WorkItems
+ # Create and link operations are not run inside a transaction in this class
+ # because CreateFromTaskService also creates a transaction.
+ # This class should always be run inside a transaction as we could end up with
+ # new work items that were never associated with other work items as expected.
+ class CreateAndLinkService
+ def initialize(project:, current_user: nil, params: {}, spam_params:, link_params: {})
+ @create_service = CreateService.new(
+ project: project,
+ current_user: current_user,
+ params: params,
+ spam_params: spam_params
+ )
+ @project = project
+ @current_user = current_user
+ @link_params = link_params
+ end
+
+ def execute
+ create_result = @create_service.execute
+ return create_result if create_result.error?
+
+ work_item = create_result[:work_item]
+ return ::ServiceResponse.success(payload: payload(work_item)) if @link_params.blank?
+
+ result = IssueLinks::CreateService.new(work_item, @current_user, @link_params).execute
+
+ if result[:status] == :success
+ ::ServiceResponse.success(payload: payload(work_item))
+ else
+ ::ServiceResponse.error(message: result[:message], http_status: 404)
+ end
+ end
+
+ private
+
+ def payload(work_item)
+ { work_item: work_item }
+ end
+ end
+end
diff --git a/app/services/work_items/create_from_task_service.rb b/app/services/work_items/create_from_task_service.rb
new file mode 100644
index 00000000000..4203c96e676
--- /dev/null
+++ b/app/services/work_items/create_from_task_service.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module WorkItems
+ class CreateFromTaskService
+ def initialize(work_item:, current_user: nil, work_item_params: {}, spam_params:)
+ @work_item = work_item
+ @current_user = current_user
+ @work_item_params = work_item_params
+ @spam_params = spam_params
+ @errors = []
+ end
+
+ def execute
+ transaction_result = ApplicationRecord.transaction do
+ create_and_link_result = CreateAndLinkService.new(
+ project: @work_item.project,
+ current_user: @current_user,
+ params: @work_item_params.slice(:title, :work_item_type_id),
+ spam_params: @spam_params,
+ link_params: { target_issuable: @work_item }
+ ).execute
+
+ if create_and_link_result.error?
+ @errors += create_and_link_result.errors
+ raise ActiveRecord::Rollback
+ end
+
+ replacement_result = TaskListReferenceReplacementService.new(
+ work_item: @work_item,
+ work_item_reference: create_and_link_result[:work_item].to_reference,
+ line_number_start: @work_item_params[:line_number_start],
+ line_number_end: @work_item_params[:line_number_end],
+ title: @work_item_params[:title],
+ lock_version: @work_item_params[:lock_version]
+ ).execute
+
+ if replacement_result.error?
+ @errors += replacement_result.errors
+ raise ActiveRecord::Rollback
+ end
+
+ create_and_link_result
+ end
+
+ return transaction_result if transaction_result
+
+ ::ServiceResponse.error(message: @errors, http_status: 422)
+ end
+ end
+end
diff --git a/app/services/work_items/task_list_reference_replacement_service.rb b/app/services/work_items/task_list_reference_replacement_service.rb
new file mode 100644
index 00000000000..1044a4feb88
--- /dev/null
+++ b/app/services/work_items/task_list_reference_replacement_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module WorkItems
+ class TaskListReferenceReplacementService
+ STALE_OBJECT_MESSAGE = 'Stale work item. Check lock version'
+
+ def initialize(work_item:, work_item_reference:, line_number_start:, line_number_end:, title:, lock_version:)
+ @work_item = work_item
+ @work_item_reference = work_item_reference
+ @line_number_start = line_number_start
+ @line_number_end = line_number_end
+ @title = title
+ @lock_version = lock_version
+ end
+
+ def execute
+ return ::ServiceResponse.error(message: STALE_OBJECT_MESSAGE) if @work_item.lock_version > @lock_version
+ return ::ServiceResponse.error(message: 'line_number_start must be greater than 0') if @line_number_start < 1
+ return ::ServiceResponse.error(message: 'line_number_end must be greater or equal to line_number_start') if @line_number_end < @line_number_start
+ return ::ServiceResponse.error(message: "Work item description can't be blank") if @work_item.description.blank?
+
+ source_lines = @work_item.description.split("\n")
+ markdown_task_first_line = source_lines[@line_number_start - 1]
+ task_line = Taskable::ITEM_PATTERN.match(markdown_task_first_line)
+
+ return ::ServiceResponse.error(message: "Unable to detect a task on line #{@line_number_start}") unless task_line
+
+ captures = task_line.captures
+
+ markdown_task_first_line.sub!(Taskable::ITEM_PATTERN, "#{captures[0]} #{captures[1]} #{@work_item_reference}+")
+
+ source_lines[@line_number_start - 1] = markdown_task_first_line
+ remove_additional_lines!(source_lines)
+
+ @work_item.update!(description: source_lines.join("\n"))
+
+ ::ServiceResponse.success
+ rescue ActiveRecord::StaleObjectError
+ ::ServiceResponse.error(message: STALE_OBJECT_MESSAGE)
+ end
+
+ private
+
+ def remove_additional_lines!(source_lines)
+ return if @line_number_end <= @line_number_start
+
+ source_lines.delete_if.each_with_index do |_line, index|
+ index >= @line_number_start && index < @line_number_end
+ end
+ end
+ end
+end
diff --git a/app/uploaders/content_type_whitelist.rb b/app/uploaders/content_type_whitelist.rb
index 64bde16cb69..82c6b9b3a61 100644
--- a/app/uploaders/content_type_whitelist.rb
+++ b/app/uploaders/content_type_whitelist.rb
@@ -30,7 +30,7 @@ module ContentTypeWhitelist
content_type = mime_magic_content_type(new_file.path)
unless whitelisted_content_type?(content_type)
- message = I18n.translate(:"errors.messages.content_type_whitelist_error", allowed_types: Array(content_type_whitelist).join(", "))
+ message = I18n.t(:"errors.messages.content_type_whitelist_error", allowed_types: Array(content_type_whitelist).join(", "))
raise CarrierWave::IntegrityError, message
end
end
diff --git a/app/validators/color_validator.rb b/app/validators/color_validator.rb
index 974dfbbf394..d108e4c5426 100644
--- a/app/validators/color_validator.rb
+++ b/app/validators/color_validator.rb
@@ -12,11 +12,13 @@
# end
#
class ColorValidator < ActiveModel::EachValidator
- PATTERN = /\A\#(?:[0-9A-Fa-f]{3}){1,2}\Z/.freeze
-
def validate_each(record, attribute, value)
- unless value =~ PATTERN
- record.errors.add(attribute, "must be a valid color code")
+ case value
+ when NilClass then return
+ when ::Gitlab::Color then return if value.valid?
+ when ::String then return if ::Gitlab::Color.new(value).valid?
end
+
+ record.errors.add(attribute, "must be a valid color code")
end
end
diff --git a/app/validators/import/gitlab_projects/remote_file_validator.rb b/app/validators/import/gitlab_projects/remote_file_validator.rb
new file mode 100644
index 00000000000..67bf102e928
--- /dev/null
+++ b/app/validators/import/gitlab_projects/remote_file_validator.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Import
+ module GitlabProjects
+ # Validates the given object's #content_type and #content_length accordingly
+ # with the Project Import requirements
+ class RemoteFileValidator < ActiveModel::Validator
+ FILE_SIZE_LIMIT = 10.gigabytes
+ ALLOWED_CONTENT_TYPES = [
+ 'application/gzip',
+ # S3 uses different file types
+ 'application/x-tar',
+ 'application/x-gzip'
+ ].freeze
+
+ def validate(record)
+ validate_content_length(record)
+ validate_content_type(record)
+ end
+
+ private
+
+ def validate_content_length(record)
+ if record.content_length.to_i <= 0
+ record.errors.add(:content_length, :size_too_small, file_size: humanize(1.byte))
+ elsif record.content_length > FILE_SIZE_LIMIT
+ record.errors.add(:content_length, :size_too_big, file_size: humanize(FILE_SIZE_LIMIT))
+ end
+ end
+
+ def humanize(number)
+ ActiveSupport::NumberHelper.number_to_human_size(number)
+ end
+
+ def validate_content_type(record)
+ return if ALLOWED_CONTENT_TYPES.include?(record.content_type)
+
+ record.errors.add(:content_type, "'%{content_type}' not allowed. (Allowed: %{allowed})" % {
+ content_type: record.content_type,
+ allowed: ALLOWED_CONTENT_TYPES.join(', ')
+ })
+ end
+ end
+ end
+end
diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
index 20be49f9eae..19258ee7677 100644
--- a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
+++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
@@ -2,8 +2,8 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"global": [
{
- "field" : "SECURE_ANALYZERS_PREFIX",
- "label" : "Image prefix",
+ "field": "SECURE_ANALYZERS_PREFIX",
+ "label": "Image prefix",
"type": "string",
"default_value": "",
"value": "",
diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml
index dbfc7bf1046..00e5650b551 100644
--- a/app/views/admin/abuse_reports/_abuse_report.html.haml
+++ b/app/views/admin/abuse_reports/_abuse_report.html.haml
@@ -25,9 +25,9 @@
%td
- if user
= link_to _('Remove user & report'), admin_abuse_report_path(abuse_report, remove_user: true),
- data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name } }, remote: true, method: :delete, class: "gl-button btn btn-block btn-danger js-remove-tr"
+ data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name }, confirm_btn_variant: "danger" }, aria: { label: _('Remove user & report') }, remote: true, method: :delete, class: "gl-button btn btn-block btn-danger js-remove-tr"
- if user && !user.blocked?
- = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-default btn-block"
+ = link_to _('Block user'), block_admin_user_path(user), data: { confirm: _('USER WILL BE BLOCKED! Are you sure?') }, aria: { label: _('Block user') }, method: :put, class: "gl-button btn btn-default btn-block"
- else
.gl-button.btn.btn-default.disabled.btn-block
= _('Already blocked')
diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_default_branch.html.haml
index 8832bc02056..f5f45d7a6e9 100644
--- a/app/views/admin/application_settings/_initial_branch_name.html.haml
+++ b/app/views/admin/application_settings/_default_branch.html.haml
@@ -1,13 +1,17 @@
-= form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_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>"
%fieldset
.form-group
- = f.label :default_branch_name, _('Default initial branch name'), class: 'label-light'
+ = f.label :default_branch_name, _('Initial default branch name'), class: 'label-light'
= f.text_field :default_branch_name, placeholder: Gitlab::DefaultBranch.value, class: 'form-control gl-form-input'
%span.form-text.text-muted
= (s_("AdminSettings|If not specified at the group or instance level, the default is %{default_initial_branch_name}. Does not affect existing repositories.") % { default_initial_branch_name: fallback_branch_name } ).html_safe
+ = render 'shared/default_branch_protection', f: f
+
+ = render_if_exists 'admin/application_settings/group_owners_can_manage_default_branch_protection_setting', form: f
+
= f.submit _('Save changes'), class: 'gl-button btn-confirm'
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index c83e28d7f0b..d9c0a01beb0 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -22,15 +22,15 @@
= 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, _('AWS access key ID (Optional)'), 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')
+ = _('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, _('AWS secret access key (Optional)'), 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')
+ = _('Only required if not using role instance credentials.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml
index 08befa59952..11830fac336 100644
--- a/app/views/admin/application_settings/_prometheus.html.haml
+++ b/app/views/admin/application_settings/_prometheus.html.haml
@@ -13,7 +13,7 @@
- unless Gitlab::Metrics.metrics_folder_present?
.form-text.text-muted
%strong.cred= _("WARNING:")
- = _("Environment variable %{code_start}%{environment_variable}%{code_end} does not exist or is not pointing to a valid directory.").html_safe % { environment_variable: prometheus_multiproc_dir, code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
+ = _("Environment variable %{environment_variable} does not exist or is not pointing to a valid directory.").html_safe % { environment_variable: '<code>prometheus_multiproc_dir</code>'.html_safe }
= link_to sprite_icon('question-o'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory')
.form-group
= f.label :metrics_method_call_threshold, _('Method call threshold (ms)'), class: 'label-bold'
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
index b55c2f05300..364a7cf5a8e 100644
--- a/app/views/admin/application_settings/_registry.html.haml
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -30,5 +30,13 @@
= f.number_field :container_registry_cleanup_tags_service_max_list_size, min: 0, class: 'form-control'
.form-text.text-muted
= _("The maximum number of tags that a single worker accepts for cleanup. If the number of tags goes above this limit, the list of tags to delete is truncated to this number. To remove this limit, set it to 0.")
+ .form-group
+ .form-check
+ = f.check_box :container_registry_expiration_policies_caching, class: 'form-check-input'
+ = f.label :container_registry_expiration_policies_caching, class: 'form-check-label' do
+ = _("Enable container expiration caching.")
+ .form-text.text-muted
+ = _("When enabled, cleanup polices execute faster but put more load on Redis.")
+ = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'set-cleanup-limits-to-conserve-resources')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_search_limits.html.haml b/app/views/admin/application_settings/_search_limits.html.haml
new file mode 100644
index 00000000000..945c9397f0d
--- /dev/null
+++ b/app/views/admin/application_settings/_search_limits.html.haml
@@ -0,0 +1,16 @@
+= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-search-limits-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :search_rate_limit, _('Maximum number of requests per minute for an authenticated user'), class: 'label-bold'
+ .form-text.gl-text-gray-600
+ = _("Set this number to 0 to disable the limit.")
+ = f.number_field :search_rate_limit, class: 'form-control gl-form-input'
+
+ .form-group
+ = f.label :search_rate_limit_unauthenticated, _('Maximum number of requests per minute for an unauthenticated IP address'), class: 'label-bold'
+ = f.number_field :search_rate_limit_unauthenticated, 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/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 156e7d3fb76..bce210d28d3 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -8,17 +8,17 @@
= f.label :password_authentication_enabled_for_web, class: 'form-check-label' do
= _('Allow password authentication for the web interface')
.form-text.text-muted
- = _('When inactive, an external authentication provider must be used.')
+ = _('Clear this checkbox to use an external authentication provider instead.')
.form-group
.form-check
= f.check_box :password_authentication_enabled_for_git, class: 'form-check-input'
= f.label :password_authentication_enabled_for_git, class: 'form-check-label' do
= _('Allow password authentication for Git over HTTP(S)')
.form-text.text-muted
- When inactive, a Personal Access Token
- if Gitlab::Auth::Ldap::Config.enabled?
- or LDAP password
- must be used to authenticate.
+ = _('Clear this checkbox to use a personal access token or LDAP password instead.')
+ - else
+ = _('Clear this checkbox to use a personal access token instead.')
- if omniauth_enabled? && button_based_providers.any?
%fieldset.form-group
%legend.gl-font-base.gl-mb-3.gl-border-none.gl-font-weight-bold= _('Enabled OAuth authentication sources')
diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml
index b92cf7b156a..65b2a95bcc1 100644
--- a/app/views/admin/application_settings/_sourcegraph.html.haml
+++ b/app/views/admin/application_settings/_sourcegraph.html.haml
@@ -12,7 +12,7 @@
- link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe
= s_('SourcegraphAdmin|Enable code intelligence powered by %{link_start}Sourcegraph%{link_end} on your GitLab instance\'s code views and merge requests.').html_safe % { link_start: link_start, link_end: link_end }
%span
- = link_to s_('SourcegraphAdmin|More information'), help_page_path('integration/sourcegraph.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('SourcegraphAdmin|Learn more.'), help_page_path('integration/sourcegraph.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
@@ -29,10 +29,10 @@
= f.check_box :sourcegraph_public_only, class: 'form-check-input'
= f.label :sourcegraph_public_only, s_('SourcegraphAdmin|Block on private and internal projects'), class: 'form-check-label'
.form-text.text-muted
- = s_('SourcegraphAdmin|If checked, only public projects will have code intelligence and communicate with Sourcegraph.')
+ = s_('SourcegraphAdmin|Only public projects have code intelligence enabled and communicate with Sourcegraph.')
.form-group
= f.label :sourcegraph_url, s_('SourcegraphAdmin|Sourcegraph URL'), class: 'label-bold'
- = f.text_field :sourcegraph_url, class: 'form-control gl-form-input', placeholder: s_('SourcegraphAdmin|e.g. https://sourcegraph.example.com')
+ = f.text_field :sourcegraph_url, class: 'form-control gl-form-input', placeholder: s_('SourcegraphAdmin|https://sourcegraph.example.com')
.form-text.text-muted
= s_('SourcegraphAdmin|Configure the URL to a Sourcegraph instance which can read your GitLab projects.')
= f.submit s_('SourcegraphAdmin|Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index 326aae26d5e..02031880fab 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -27,7 +27,7 @@
%p.mb-2= s_('%{service_ping_link_start}What information is shared with GitLab Inc.?%{service_ping_link_end}').html_safe % { service_ping_link_start: service_ping_link_start, service_ping_link_end: '</a>'.html_safe }
%button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } }
- .gl-spinner.js-spinner.gl-display-none.gl-mr-2
+ = gl_loading_icon(css_class: 'js-spinner gl-display-none gl-mr-2')
.js-text.gl-display-inline= _('Preview payload')
%pre.service-data-payload-container.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
- else
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index e56c898b236..b0810d3d48a 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -2,9 +2,6 @@
= form_errors(@application_setting)
%fieldset
- = render 'shared/default_branch_protection', f: f
- = render_if_exists 'admin/application_settings/group_owners_can_manage_default_branch_protection_setting', form: f
-
= render 'shared/project_creation_levels', f: f, method: :default_project_creation, legend: s_('ProjectCreationLevel|Default project creation protection')
= render_if_exists 'admin/application_settings/default_project_deletion_protection_setting', form: f
= render_if_exists 'admin/application_settings/default_delayed_project_deletion_setting', form: f
diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml
index 0f7f0109a54..84c26da8772 100644
--- a/app/views/admin/application_settings/appearances/_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_form.html.haml
@@ -16,7 +16,7 @@
= image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview'
- if @appearance.persisted?
%br
- = link_to _('Remove header logo'), header_logos_admin_application_settings_appearances_path, data: { confirm: _("Header logo will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm"
+ = link_to _('Remove header logo'), header_logos_admin_application_settings_appearances_path, data: { confirm: _("Header logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove header logo') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm"
%hr
= f.hidden_field :header_logo_cache
= f.file_field :header_logo, class: "", accept: 'image/*'
@@ -35,7 +35,7 @@
= image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
- if @appearance.persisted?
%br
- = link_to _('Remove favicon'), favicon_admin_application_settings_appearances_path, data: { confirm: _("Favicon will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm"
+ = link_to _('Remove favicon'), favicon_admin_application_settings_appearances_path, data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm"
%hr
= f.hidden_field :favicon_cache
= f.file_field :favicon, class: '', accept: 'image/*'
@@ -67,7 +67,7 @@
= image_tag @appearance.logo_path, class: 'appearance-logo-preview'
- if @appearance.persisted?
%br
- = link_to _('Remove logo'), logo_admin_application_settings_appearances_path, data: { confirm: _("Logo will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo"
+ = link_to _('Remove logo'), logo_admin_application_settings_appearances_path, data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo"
%hr
= f.hidden_field :logo_cache
= f.file_field :logo, class: "", accept: 'image/*'
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index 18ec43407c3..762dba69e6a 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -39,7 +39,7 @@
.settings-content
= render 'registry'
-- if Feature.enabled?(:runner_registration_control)
+- if Feature.enabled?(:runner_registration_control, default_enabled: :yaml)
%section.settings.as-runner.no-animate#js-runner-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index 90183b028f0..ea35b7ab9c4 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -48,6 +48,17 @@
.settings-content
= render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' }
+%section.settings.as-search-limits.no-animate#js-search-limits-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Search rate limits')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Set rate limits for searches performed by web or API requests.')
+ .settings-content
+ = render 'search_limits'
+
%section.settings.as-deprecated-limits.no-animate#js-deprecated-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml
index ac200002cd2..c3a39ddf86d 100644
--- a/app/views/admin/application_settings/repository.html.haml
+++ b/app/views/admin/application_settings/repository.html.haml
@@ -5,13 +5,13 @@
%section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
- = _('Default initial branch name')
+ = _('Default branch')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = s_('AdminSettings|The default name for the initial branch of new repositories created in the instance.')
+ = s_('AdminSettings|Set the initial name and protections for the default branch of new repositories created in the instance.')
.settings-content
- = render 'initial_branch_name'
+ = render 'default_branch'
%section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
diff --git a/app/views/admin/applications/_delete_form.html.haml b/app/views/admin/applications/_delete_form.html.haml
index d348ad507c2..16ec8014c5e 100644
--- a/app/views/admin/applications/_delete_form.html.haml
+++ b/app/views/admin/applications/_delete_form.html.haml
@@ -1,4 +1,5 @@
-- submit_btn_css ||= 'gl-button btn btn-danger btn-sm'
-= form_tag admin_application_path(application) do
- %input{ :name => "_method", :type => "hidden", :value => "delete" }/
- = submit_tag 'Destroy', class: submit_btn_css, data: { confirm: _('Are you sure?') }
+
+- submit_btn_css ||= 'gl-button btn btn-danger btn-sm js-application-delete-button'
+%button{ class: submit_btn_css, data: { path: admin_application_path(application), name: application.name } }
+ = _('Destroy')
+
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index 28a7bd1820a..86a4ab00ba3 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -33,3 +33,5 @@
%td= render 'delete_form', application: application
= paginate @applications, theme: 'gitlab'
+
+.js-application-delete-modal
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index b68c22b6942..3e698f0508c 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -16,7 +16,7 @@
- else
= _('Your message here')
-= form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f|
+= gitlab_ui_form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f|
= form_errors(@broadcast_message)
.form-group.row.mt-4
@@ -52,9 +52,16 @@
.col-sm-2.col-form-label.pt-0
= f.label :starts_at, _("Dismissable")
.col-sm-10
- = f.check_box :dismissable
- = f.label :dismissable do
- = _('Allow users to dismiss the broadcast message')
+ = f.gitlab_ui_checkbox_component :dismissable, _('Allow users to dismiss the broadcast message')
+ - if Feature.enabled?(:role_targeted_broadcast_messages, default_enabled: :yaml)
+ .form-group.row
+ .col-sm-2.col-form-label
+ = f.label :target_access_levels, _('Target roles')
+ .col-sm-10
+ - target_access_level_options.each do |human_access_level, access_level|
+ = f.gitlab_ui_checkbox_component :target_access_levels, human_access_level, checked_value: access_level, unchecked_value: false, checkbox_options: { multiple: true }
+ .form-text.text-muted
+ = _('The broadcast message displays only to users in projects and groups who have these roles.')
.form-group.row.js-toggle-colors-container.toggle-colors.hide
.col-sm-2.col-form-label
= f.label :font, _("Font Color")
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index 3f07bea7840..54c2a9d5250 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -1,10 +1,11 @@
- breadcrumb_title _("Messages")
- page_title _("Broadcast Messages")
+- targeted_broadcast_messages_enabled = Feature.enabled?(:role_targeted_broadcast_messages, default_enabled: :yaml)
%h3.page-title
= _('Broadcast Messages')
%p.light
- = _('Broadcast messages are displayed for every user and can be used to notify users about scheduled maintenance, recent upgrades and more.')
+ = _('Use banners and notifications to notify your users about scheduled maintenance, recent upgrades, and more.')
= render 'form'
@@ -19,8 +20,10 @@
%th= _('Preview')
%th= _('Starts')
%th= _('Ends')
- %th= _(' Target Path')
- %th= _(' Type')
+ - if targeted_broadcast_messages_enabled
+ %th= _('Target roles')
+ %th= _('Target Path')
+ %th= _('Type')
%th &nbsp;
%tbody
- @broadcast_messages.each do |message|
@@ -33,6 +36,9 @@
= message.starts_at
%td
= message.ends_at
+ - if targeted_broadcast_messages_enabled
+ %td
+ = target_access_levels_display(message.target_access_levels)
%td
= message.target_path
%td
diff --git a/app/views/admin/dashboard/_security_newsletter_callout.html.haml b/app/views/admin/dashboard/_security_newsletter_callout.html.haml
index 3aba91e8765..aced997bada 100644
--- a/app/views/admin/dashboard/_security_newsletter_callout.html.haml
+++ b/app/views/admin/dashboard/_security_newsletter_callout.html.haml
@@ -4,7 +4,6 @@
title: s_('AdminArea|Get security updates from GitLab and stay up to date'),
variant: :tip,
alert_class: 'js-security-newsletter-callout',
- is_contained: true,
alert_data: { feature_id: Users::CalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' },
close_button_data: { testid: 'close-security-newsletter-callout' } do
.gl-alert-body
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 91a018121c0..0c3ce1f3fa4 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -27,12 +27,9 @@
- if @group.new_record?
.form-group.row
.offset-sm-2.col-sm-10
- .gl-alert.gl-alert-
- .gl-alert-container
- = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-body
- = render 'shared/group_tips'
+ = render 'shared/global_alert', dismissible: false do
+ .gl-alert-body
+ = render 'shared/group_tips'
.form-actions
= f.submit _('Create group'), class: "gl-button btn btn-confirm"
= link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-default btn-cancel"
diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
index 459df5c8d85..bd63172a0ee 100644
--- a/app/views/admin/hooks/_form.html.haml
+++ b/app/views/admin/hooks/_form.html.haml
@@ -3,7 +3,7 @@
.form-group
= form.label :url, _('URL'), class: 'label-bold'
= form.text_field :url, class: 'form-control gl-form-input'
- %p.form-text.text-muted= _('URL must be percent-encoded if neccessary.')
+ %p.form-text.text-muted= _('URL must be percent-encoded if necessary.')
.form-group
= form.label :token, _('Secret token'), class: 'label-bold'
= form.text_field :token, class: 'form-control gl-form-input'
diff --git a/app/views/admin/runners/edit.html.haml b/app/views/admin/runners/edit.html.haml
index b65fead49ab..55fd09ac203 100644
--- a/app/views/admin/runners/edit.html.haml
+++ b/app/views/admin/runners/edit.html.haml
@@ -25,15 +25,12 @@
- if project
%tr
%td
- .gl-alert.gl-alert-danger
- .gl-alert-container
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-body
- %strong
- = project.full_name
- .gl-alert-actions
- = 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'
+ = render 'shared/global_alert',
+ variant: :danger,
+ dismissible: false,
+ title: project.full_name do
+ .gl-alert-actions
+ = 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
diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml
index 2bfe905fb9d..cd6df5f30f3 100644
--- a/app/views/admin/spam_logs/_spam_log.html.haml
+++ b/app/views/admin/spam_logs/_spam_log.html.haml
@@ -24,7 +24,7 @@
%td
- if user
= link_to _('Remove user'), admin_spam_log_path(spam_log, remove_user: true),
- data: { confirm: _("USER %{user_name} WILL BE REMOVED! Are you sure?") % { user_name: user.name } }, method: :delete, class: "gl-button btn btn-sm btn-danger"
+ data: { confirm: _("USER %{user_name} WILL BE REMOVED! Are you sure?") % { user_name: user.name }, confirm_btn_variant: 'danger' }, aria: { label: _('Remove user') }, method: :delete, class: "gl-button btn btn-sm btn-danger"
%td
- if spam_log.submitted_as_ham?
.gl-button.btn.btn-default.btn-sm.disabled.gl-mb-3
diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml
index 21a1d74a8c6..c40484ea494 100644
--- a/app/views/admin/topics/_form.html.haml
+++ b/app/views/admin/topics/_form.html.haml
@@ -27,7 +27,7 @@
= topic_icon(@topic, alt: _('Topic avatar'), class: 'avatar topic-avatar s90')
= render 'shared/choose_avatar_button', f: f
- if @topic.avatar?
- = link_to _('Remove avatar'), admin_topic_avatar_path(@topic), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'gl-button btn btn-danger-secondary gl-mt-2'
+ .js-remove-topic-avatar{ data: { path: admin_topic_avatar_path(@topic) } }
- if @topic.new_record?
.form-actions
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index ca14d898d79..e429a16d5ec 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -27,8 +27,6 @@
= render_if_exists 'admin/users/gma_user_badge'
.gl-my-3.gl-display-flex.gl-flex-wrap.gl-my-n2.gl-mx-n2
- .gl-p-2
- #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) }
- if @user != current_user
.gl-p-2
- if impersonation_enabled? && @user.can?(:log_in)
@@ -36,6 +34,8 @@
- if can_force_email_confirmation?(@user)
%button.btn.gl-button.btn-info.js-confirm-modal-button{ data: confirm_user_data(@user) }
= _('Confirm user')
+ .gl-p-2
+ #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) }
= gl_tabs_nav do
= gl_tab_link_to _("Account"), admin_user_path(@user)
= gl_tab_link_to _("Groups and projects"), projects_admin_user_path(@user)
diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml
index 6fdf383d571..ad7ce57ebda 100644
--- a/app/views/admin/users/_users.html.haml
+++ b/app/views/admin/users/_users.html.haml
@@ -60,21 +60,12 @@
= hidden_field_tag :sort, @sort
= sprite_icon('search', css_class: 'search-icon')
= button_tag s_('AdminUsers|Search users') if Rails.env.test?
- .dropdown.gl-ml-3
- = label_tag 'Sort by', nil, class: 'label-bold'
- - toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name
- = dropdown_toggle(toggle_text, { toggle: 'dropdown' })
- %ul.dropdown-menu.dropdown-menu-right
- %li.dropdown-header
- = s_('AdminUsers|Sort by')
- %li
- - users_sort_options_hash.each do |value, title|
- = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do
- = title
+ .dropdown.gl-sm-ml-3
+ = label_tag s_('AdminUsers|Sort by')
+ = gl_redirect_listbox_tag admin_users_sort_options(filter: params[:filter], search_query: params[:search_query]), @sort, data: { right: true }
#js-admin-users-app{ data: admin_users_data_attributes(@users) }
- .gl-spinner-container.gl-my-7
- %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } }
+ = gl_loading_icon(size: 'lg', css_class: 'gl-my-7')
= paginate_collection @users
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
index 3a7f7a241ac..483c767d029 100644
--- a/app/views/ci/variables/_variable_row.html.haml
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -1,23 +1,16 @@
- form_field = local_assigns.fetch(:form_field, nil)
- variable = local_assigns.fetch(:variable, nil)
-- only_key_value = local_assigns.fetch(:only_key_value, false)
- id = variable&.id
- variable_type = variable&.variable_type
- key = variable&.key
- value = variable&.value
-- is_protected_default = ci_variable_protected_by_default?
-- is_protected = ci_variable_protected?(variable, only_key_value)
-- is_masked_default = false
-- is_masked = ci_variable_masked?(variable, only_key_value)
- id_input_name = "#{form_field}[variables_attributes][][id]"
- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]"
- variable_type_input_name = "#{form_field}[variables_attributes][][variable_type]"
- key_input_name = "#{form_field}[variables_attributes][][key]"
- value_input_name = "#{form_field}[variables_attributes][][secret_value]"
-- protected_input_name = "#{form_field}[variables_attributes][][protected]"
-- masked_input_name = "#{form_field}[variables_attributes][][masked]"
%li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } }
.ci-variable-row-body.border-bottom
@@ -40,25 +33,5 @@
%p.masking-validation-error.gl-field-error.hide
= s_("CiVariables|Cannot use Masked Variable with current value")
= link_to sprite_icon('question-o'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
- - unless only_key_value
- .ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0
- .gl-mr-3
- = s_("CiVariable|Protected")
- = render "shared/buttons/project_feature_toggle", is_checked: is_protected, label: s_("CiVariable|Toggle protected") do
- %input{ type: "hidden",
- class: 'js-ci-variable-input-protected js-project-feature-toggle-input',
- name: protected_input_name,
- value: is_protected,
- data: { default: is_protected_default.to_s } }
- .ci-variable-body-item.ci-variable-masked-item.table-section.section-20.mr-0.border-top-0
- .gl-mr-3
- = s_("CiVariable|Masked")
- = render "shared/buttons/project_feature_toggle", is_checked: is_masked, label: s_("CiVariable|Toggle masked"), class_list: "js-project-feature-toggle project-feature-toggle qa-variable-masked" do
- %input{ type: "hidden",
- class: 'js-ci-variable-input-masked js-project-feature-toggle-input',
- name: masked_input_name,
- value: is_masked,
- data: { default: is_masked_default.to_s } }
- = render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable
%button.gl-button.btn.btn-default.btn-icon.js-row-remove-button.ci-variable-row-remove-button.table-section{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
= sprite_icon('close')
diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml
index 1ca4f9c670e..6fb3f26ff4f 100644
--- a/app/views/clusters/clusters/_banner.html.haml
+++ b/app/views/clusters/clusters/_banner.html.haml
@@ -9,7 +9,6 @@
= render 'shared/global_alert',
variant: :warning,
alert_class: 'hidden js-cluster-api-unreachable',
- is_contained: true,
close_button_class: 'js-close' do
.gl-alert-body
= s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.')
@@ -17,7 +16,6 @@
= render 'shared/global_alert',
variant: :warning,
alert_class: 'hidden js-cluster-authentication-failure js-cluster-api-unreachable',
- is_contained: true,
close_button_class: 'js-close' do
.gl-alert-body
= s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.')
diff --git a/app/views/clusters/clusters/_cluster_list.html.haml b/app/views/clusters/clusters/_cluster_list.html.haml
deleted file mode 100644
index e5e1b68225e..00000000000
--- a/app/views/clusters/clusters/_cluster_list.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-- if !clusters.empty?
- .top-area.adjust
- .gl-display-block.gl-text-right.gl-my-4.gl-w-full
- - if clusterable.can_add_cluster?
- = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-confirm js-add-cluster gl-py-2', data: { qa_selector: 'integrate_kubernetes_cluster_button' }
- - else
- %span.btn.gl-button.btn-confirm.js-add-cluster.disabled.gl-py-2
- = s_("ClusterIntegration|Connect cluster with certificate")
-
-#js-clusters-list-app{ data: js_clusters_list_data(clusterable) }
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 9d249931a34..3a4632affdc 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -1,11 +1,10 @@
- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
-.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path } }
- .gl-alert-container
- %button.js-close.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon{ type: 'button', 'aria-label' => _('Dismiss') }
- = sprite_icon('close', size: 16, css_class: 'gl-icon')
- = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- %h4.gl-alert-title= s_('ClusterIntegration|Did you know?')
- %p.gl-alert-body= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
- %a.gl-button.btn-confirm.text-decoration-none{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
- = s_("ClusterIntegration|Apply for credit")
+= render 'shared/global_alert',
+ title: s_('ClusterIntegration|Did you know?'),
+ alert_class: 'gcp-signup-offer',
+ alert_data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path } do
+ .gl-alert-body
+ = s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
+ .gl-alert-actions
+ %a.gl-button.btn-confirm.text-decoration-none{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
+ = s_("ClusterIntegration|Apply for credit")
diff --git a/app/views/clusters/clusters/_multiple_clusters_message.html.haml b/app/views/clusters/clusters/_multiple_clusters_message.html.haml
index ed95744c11d..04c1f9b6e7a 100644
--- a/app/views/clusters/clusters/_multiple_clusters_message.html.haml
+++ b/app/views/clusters/clusters/_multiple_clusters_message.html.haml
@@ -2,5 +2,5 @@
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
- help_link_end = '</a>'.html_safe
-%p
- = s_('ClusterIntegration|If you are setting up multiple clusters and are using Auto DevOps, %{help_link_start}read this first%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: autodevops_help_url }, help_link_end: help_link_end }
+%p.gl-font-weight-bold
+ = s_('ClusterIntegration|Using AutoDevOps with multiple clusters? %{help_link_start}Read this first.%{help_link_end}').html_safe % { help_link_start: help_link_start % { url: autodevops_help_url }, help_link_end: help_link_end }
diff --git a/app/views/clusters/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml
index 31add011bfa..bda774ee780 100644
--- a/app/views/clusters/clusters/_sidebar.html.haml
+++ b/app/views/clusters/clusters/_sidebar.html.haml
@@ -1,5 +1,5 @@
-%h4.gl-mt-0
- = s_('ClusterIntegration|Add a Kubernetes cluster integration')
+%h3
+ = s_('ClusterIntegration|Connect a Kubernetes cluster')
%p
= clusterable.sidebar_text
%p
diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
index c10983a5405..826dc749dad 100644
--- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
+++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
@@ -3,10 +3,10 @@
- logo_path = local_assigns.fetch(:logo_path)
- label = local_assigns.fetch(:label)
- last = local_assigns.fetch(:last, false)
-- classes = ["btn btn-light btn-outline flex-fill d-inline-flex flex-column justify-content-center align-items-center w-50 js-create-#{provider}-cluster-button"]
-- conditional_classes = [('mr-3' unless last), ('active' if is_current_provider)]
+- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-w-half js-create-#{provider}-cluster-button"]
+- conditional_classes = [('gl-mr-5' unless last), ('active' if is_current_provider)]
= link_to clusterable.new_path(provider: provider), class: classes + conditional_classes do
- .svg-content.p-2= image_tag logo_path, alt: label, class: 'gl-w-64 gl-h-64'
+ .svg-content.gl-p-3= image_tag logo_path, alt: label, class: 'gl-w-64 gl-h-64'
%span
= label
diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
index aee355bbf71..321fb854e0d 100644
--- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
+++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
@@ -1,10 +1,10 @@
- gke_label = s_('ClusterIntegration|Google GKE')
- eks_label = s_('ClusterIntegration|Amazon EKS')
-- create_cluster_label = s_('ClusterIntegration|Create cluster on')
-.d-flex.flex-column.p-3
- %h4.mb-3
+- create_cluster_label = s_('ClusterIntegration|Where do you want to create a cluster?')
+.gl-p-5
+ %h4.gl-mb-5
= create_cluster_label
- .d-flex
+ .gl-display-flex
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
locals: { provider: 'aws', label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg' }
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
diff --git a/app/views/clusters/clusters/connect.html.haml b/app/views/clusters/clusters/connect.html.haml
new file mode 100644
index 00000000000..1043f78bd3c
--- /dev/null
+++ b/app/views/clusters/clusters/connect.html.haml
@@ -0,0 +1,11 @@
+- @content_class = 'limit-container-width' unless fluid_layout
+- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path
+- breadcrumb_title _('Connect a cluster')
+- page_title _('Connect a Kubernetes Cluster')
+
+.row.gl-mt-3
+ .col-md-3
+ = render 'sidebar'
+ .col-md-9
+ #js-cluster-new{ data: js_cluster_new }
+ = render 'clusters/clusters/user/form'
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index c8105fd1152..58b8e8b1003 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -67,20 +67,20 @@
label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.')
- = link_to _('More information'), help_page_path('user/project/clusters/add_gke_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('user/project/clusters/add_gke_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'),
label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
- = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
- = link_to _('More information'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer'
.form-group.js-gke-cluster-creation-submit-container
= field.submit s_('ClusterIntegration|Create Kubernetes cluster'),
diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml
index 457e34b306a..abe9cc9f27d 100644
--- a/app/views/clusters/clusters/index.html.haml
+++ b/app/views/clusters/clusters/index.html.haml
@@ -4,9 +4,5 @@
= render_gcp_signup_offer
.clusters-container
- - if display_cluster_agents?(clusterable)
- .gl-my-6
- .js-clusters-main-view{ data: js_clusters_data(clusterable) }
-
- - else
- = render 'cluster_list', clusters: @clusters
+ .gl-my-6
+ .js-clusters-main-view{ data: js_clusters_list_data(clusterable) }
diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml
index 7af7a812338..a184f412565 100644
--- a/app/views/clusters/clusters/new.html.haml
+++ b/app/views/clusters/clusters/new.html.haml
@@ -1,9 +1,8 @@
-- breadcrumb_title _('Kubernetes')
-- page_title _('Kubernetes Cluster')
+- @content_class = 'limit-container-width' unless fluid_layout
+- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path
+- breadcrumb_title _('Create a cluster')
+- page_title _('Create a Kubernetes cluster')
- provider = params[:provider]
-- active_tab = params[:tab] || local_assigns.fetch(:active_tab, 'create')
-- is_active_tab_create = active_tab === 'create'
-- is_active_tab_add = active_tab === 'add'
= render_gcp_signup_offer
@@ -11,21 +10,8 @@
.col-md-3
= render 'sidebar'
.col-md-9
- = gl_tabs_nav({ class: 'nav-justified' }) do
- = gl_tab_link_to clusterable.new_path(tab: 'create'), { item_active: is_active_tab_create } do
- %span= create_new_cluster_label(provider: params[:provider])
- = gl_tab_link_to s_('ClusterIntegration|Connect existing cluster'), clusterable.new_path(tab: 'add'), { item_active: is_active_tab_add, qa_selector: 'add_existing_cluster_tab' }
+ = render 'clusters/clusters/cloud_providers/cloud_provider_selector'
- .tab-content
- - if is_active_tab_create
- .tab-pane.active{ role: 'tabpanel' }
- = render 'clusters/clusters/cloud_providers/cloud_provider_selector'
-
- - if ['aws', 'gcp'].include?(provider)
- .p-3.border-top
- = render "clusters/clusters/#{provider}/new"
-
- - if is_active_tab_add
- .tab-pane.active.gl-p-5{ role: 'tabpanel' }
- #js-cluster-new{ data: js_cluster_new }
- = render 'clusters/clusters/user/form'
+ - if ['aws', 'gcp'].include?(provider)
+ .gl-p-5.gl-border-1.gl-border-t-solid.gl-border-gray-100
+ = render "clusters/clusters/#{provider}/new"
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index 29af79cee5f..2dd15ebd266 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -1,6 +1,6 @@
-- more_info_link = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md',
+- more_info_link = link_to _('Learn more.'), help_page_path('user/project/clusters/add_remove_clusters.md',
anchor: 'add-existing-cluster'), target: '_blank', rel: 'noopener noreferrer'
-- rbac_help_link = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md',
+- rbac_help_link = link_to _('Learn more.'), help_page_path('user/project/clusters/add_remove_clusters.md',
anchor: 'access-controls'), target: '_blank', rel: 'noopener noreferrer'
- api_url_help_text = s_('ClusterIntegration|The URL used to access the Kubernetes API.')
@@ -47,13 +47,13 @@
label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
- = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
- = link_to _('More information'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer'
= field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field|
- if @user_cluster.allow_user_defined_namespace?
diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml
index ec07c636b79..7c948260d4b 100644
--- a/app/views/dashboard/_activities.html.haml
+++ b/app/views/dashboard/_activities.html.haml
@@ -6,4 +6,4 @@
.content_list
.loading
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md')
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index d5cd4b66e2b..601b6a8b1a7 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,4 +1,2 @@
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
- .loading-container.text-center.prepend-top-20
- .gl-spinner.gl-spinner-md
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index 2c6c721a51c..c932b416b66 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -50,12 +50,12 @@
.todo-actions.gl-ml-3
- if todo.pending?
= link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do
+ = gl_loading_icon(inline: true)
Done
- %span.gl-spinner.ml-1
= link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do
+ = gl_loading_icon(inline: true)
Undo
- %span.gl-spinner.ml-1
- else
= link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do
+ = gl_loading_icon(inline: true)
Add a to do
- %span.gl-spinner.ml-1
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index f6dc62e1d44..1d711f366c4 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -2,6 +2,7 @@
- page_title _("To-Do List")
- header_title _("To-Do List"), dashboard_todos_path
+= render_two_factor_auth_recovery_settings_check
= render_dashboard_ultimate_trial(current_user)
- add_page_specific_style 'page_bundles/todos'
@@ -22,11 +23,11 @@
- if @allowed_todos.any?(&:pending?)
.gl-mr-3
= link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'gl-button btn btn-default btn-loading align-items-center js-todos-mark-all', method: :delete, data: { href: destroy_all_dashboard_todos_path(todos_filter_params) } do
+ = gl_loading_icon(inline: true)
= s_("Todos|Mark all as done")
- %span.gl-spinner.ml-1
= link_to bulk_restore_dashboard_todos_path, class: 'gl-button btn btn-default btn-loading align-items-center js-todos-undo-all hidden', method: :patch , data: { href: bulk_restore_dashboard_todos_path(todos_filter_params) } do
+ = gl_loading_icon(inline: true)
= s_("Todos|Undo mark all as done")
- %span.gl-spinner.ml-1
.todos-filters
.issues-details-filters.row-content-block.second-block
diff --git a/app/views/devise/shared/_email_opted_in.html.haml b/app/views/devise/shared/_email_opted_in.html.haml
index 3817f9f651d..898b8f31f1d 100644
--- a/app/views/devise/shared/_email_opted_in.html.haml
+++ b/app/views/devise/shared/_email_opted_in.html.haml
@@ -1,4 +1,4 @@
-- return unless Gitlab.dev_env_or_com?
+- return unless Gitlab.com?
.gl-mb-3.js-email-opt-in.hidden
.gl-font-weight-bold.gl-mb-3
diff --git a/app/views/devise/shared/_terms_of_service_notice.html.haml b/app/views/devise/shared/_terms_of_service_notice.html.haml
index 75d567a03fd..1c6dc1f2d5d 100644
--- a/app/views/devise/shared/_terms_of_service_notice.html.haml
+++ b/app/views/devise/shared/_terms_of_service_notice.html.haml
@@ -1,7 +1,7 @@
- return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms?
%p.gl-text-gray-500.gl-mt-5.gl-mb-0
- - if Gitlab.dev_env_or_com?
+ - if Gitlab.com?
= html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the GitLab %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text,
link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe }
- else
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 075eb99fc36..477f6c73388 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -11,7 +11,8 @@
%button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button", class: ("js-toggle-lazy-diff" unless expanded) }
= sprite_icon('chevron-up', css_class: "js-sidebar-collapse #{'hidden' unless expanded}")
= sprite_icon('chevron-down', css_class: "js-sidebar-expand #{'hidden' if expanded}")
- = _('Toggle thread')
+ %span.js-sidebar-collapse{ class: "#{'hidden' unless expanded}" }= _('Hide thread')
+ %span.js-sidebar-expand{ class: "#{'hidden' if expanded}" }= _('Show thread')
= link_to_member(@project, discussion.author, avatar: false)
.inline.discussion-headline-light
diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml
index 0358fc524d3..bb2bd193565 100644
--- a/app/views/explore/groups/_groups.html.haml
+++ b/app/views/explore/groups/_groups.html.haml
@@ -1,4 +1,3 @@
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
- .loading-container.text-center.prepend-top-20
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md', css_class: 'gl-mt-6')
diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml
index 2ead8fc2cfd..f02d30081b6 100644
--- a/app/views/explore/projects/_filter.html.haml
+++ b/app/views/explore/projects/_filter.html.haml
@@ -1,22 +1,9 @@
- has_label = local_assigns.fetch(:has_label, false)
- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar)
+- klass = feature_project_list_filter_bar ? 'gl-ml-3 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1' : 'gl-ml-3'
+- selected = projects_filter_selected(params[:visibility_level])
- if current_user
- .dropdown.js-project-filter-dropdown-wrap{ class: ('d-flex flex-grow-1 flex-shrink-1' if feature_project_list_filter_bar) }
- %button.dropdown-menu-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' }
- - unless has_label
- %span= _("Visibility:")
- - if params[:visibility_level].present?
- = visibility_level_label(params[:visibility_level].to_i)
- - else
- = _('Any')
- = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
- %ul.dropdown-menu.dropdown-menu-right
- %li
- = link_to filter_projects_path(visibility_level: nil) do
- = _('Any')
- - Gitlab::VisibilityLevel.values.each do |level|
- %li{ class: active_when(level.to_s == params[:visibility_level]) || 'light' }
- = link_to filter_projects_path(visibility_level: level) do
- = visibility_level_icon(level)
- = visibility_level_label(level)
+ - unless has_label
+ %span.gl-float-left= _("Visibility:")
+ = gl_redirect_listbox_tag(projects_filter_items, selected, class: klass, data: { right: true })
diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml
index 1695d3b5539..614d9610f31 100644
--- a/app/views/groups/_activities.html.haml
+++ b/app/views/groups/_activities.html.haml
@@ -6,4 +6,4 @@
.content_list
.loading
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md')
diff --git a/app/views/groups/_archived_projects.html.haml b/app/views/groups/_archived_projects.html.haml
index 959c26acae0..21107cc22a1 100644
--- a/app/views/groups/_archived_projects.html.haml
+++ b/app/views/groups/_archived_projects.html.haml
@@ -4,5 +4,4 @@
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
- .loading-container.text-center.prepend-top-20
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md', css_class: 'gl-mt-6')
diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml
index 3b079ea00b7..5b9cd80799c 100644
--- a/app/views/groups/_import_group_from_another_instance_panel.html.haml
+++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml
@@ -23,7 +23,8 @@
= f.label :bulk_import_gitlab_access_token, s_('GroupsNew|Personal access token'), for: 'import_gitlab_token'
.gl-font-weight-normal
- pat_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/profile/personal_access_tokens') }
- = s_('GroupsNew|Navigate to user settings to find your %{link_start}personal access token%{link_end}.').html_safe % { link_start: pat_link_start, link_end: '</a>'.html_safe }
+ - short_living_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('security/token_overview', anchor: 'security-considerations') }
+ = s_('GroupsNew|Create this in the %{pat_link_start}user settings%{pat_link_end} of the source GitLab instance. For %{short_living_link_start}security reasons%{short_living_link_end}, use a short expiration date when creating the token.').html_safe % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe, short_living_link_start: short_living_link_start, short_living_link_end: '</a>'.html_safe }
= f.text_field :bulk_import_gitlab_access_token, placeholder: s_('GroupsNew|e.g. h8d3f016698e...'), class: 'gl-form-input gl-mt-3 col-xs-12 col-sm-8',
required: true,
autocomplete: 'off',
diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml
index bfd056ccdd2..ef6410ad439 100644
--- a/app/views/groups/_shared_projects.html.haml
+++ b/app/views/groups/_shared_projects.html.haml
@@ -4,5 +4,4 @@
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
- .loading-container.text-center.prepend-top-20
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index d1f56a50907..5c579cf6488 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -1,6 +1,5 @@
- add_page_specific_style 'page_bundles/members'
- page_title _('Group members')
-- groups_select_tag_data = group_select_data(@group).merge({ skip_groups: @skip_groups })
.row.gl-mt-3
.col-lg-12
@@ -11,28 +10,15 @@
= _('Group members')
%p
= html_escape(_('You can invite a new member to %{strong_start}%{group_name}%{strong_end}.')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- - if Feature.enabled?(:invite_members_group_modal, @group, default_enabled: :yaml)
- .gl-w-half.gl-xs-w-full
- .gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-mb-3
- .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } }
- .js-invite-members-trigger{ data: { variant: 'success',
- classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',
- trigger_source: 'group-members-page',
- display_text: _('Invite members') } }
- = render 'groups/invite_groups_modal', group: @group
- = render 'groups/invite_members_modal', group: @group
- - if can_admin_group_member?(@group) && Feature.disabled?(:invite_members_group_modal, @group, default_enabled: :yaml)
- %hr.gl-mt-4
- %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
- %li.nav-tab{ role: 'presentation' }
- %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _('Invite member')
- %li.nav-tab{ role: 'presentation' }
- %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _('Invite group')
- .tab-content.gitlab-tab-content
- .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
- = render_invite_member_for_group(@group, @group_member.access_level)
- .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
- = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access', groups_select_tag_data: groups_select_tag_data
+ .gl-w-half.gl-xs-w-full
+ .gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-mb-3
+ .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } }
+ .js-invite-members-trigger{ data: { variant: 'confirm',
+ classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',
+ trigger_source: 'group-members-page',
+ display_text: _('Invite members') } }
+ = render 'groups/invite_groups_modal', group: @group
+ = render 'groups/invite_members_modal', group: @group
= render_if_exists 'groups/group_members/ldap_sync'
@@ -40,5 +26,4 @@
members: @members,
invited: @invited_members,
access_requests: @requesters).to_json } }
- .loading
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(css_class: 'gl-my-5', size: 'md')
diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml
new file mode 100644
index 00000000000..1ee15557e21
--- /dev/null
+++ b/app/views/groups/harbor/repositories/index.html.haml
@@ -0,0 +1,9 @@
+- page_title _("Harbor Registry")
+- @content_class = "limit-container-width" unless fluid_layout
+
+#js-harbor-registry-list-group{ data: { endpoint: group_harbor_registries_path(@group),
+ "no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
+ "containers_error_image" => image_path('illustrations/docker-error-state.svg'),
+ "help_page_path" => help_page_path('user/packages/container_registry/index'),
+ connection_error: (!!@connection_error).to_s,
+ invalid_path_error: (!!@invalid_path_error).to_s, } }
diff --git a/app/views/groups/imports/show.html.haml b/app/views/groups/imports/show.html.haml
index 79cac364016..9cfb58da7e4 100644
--- a/app/views/groups/imports/show.html.haml
+++ b/app/views/groups/imports/show.html.haml
@@ -4,7 +4,7 @@
.save-group-loader
.center
%h2
- %i.loading.gl-spinner
+ = gl_loading_icon(size: 'md')
= page_title
%p
= s_('GroupImport|Please wait while we import the group for you. Refresh at will.')
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index f6d05959d2e..6060d697f52 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -12,6 +12,7 @@
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
+ "container_registry_importing_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'tags-temporarily-cannot-be-marked-for-deletion'),
"is_admin": current_user&.admin.to_s,
is_group_page: "true",
"group_path": @group.full_path,
diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml
index 78ce5b3e110..b2d8b9668e7 100644
--- a/app/views/groups/runners/_runner.html.haml
+++ b/app/views/groups/runners/_runner.html.haml
@@ -9,7 +9,7 @@
- if runner.locked?
= gl_badge_tag s_('Runners|locked'), variant: :warning, size: :sm
- unless runner.active?
- = gl_badge_tag s_('Runners|paused'), variant: :danger, size: :sm
+ = gl_badge_tag s_('Runners|paused'), { variant: :danger, size: :sm }, { title: s_('Runners|Not accepting jobs'), data: { toggle: 'tooltip', container: 'body' } }
.table-section.section-30
.table-mobile-header{ role: 'rowheader' }= s_('Runners|Runner')
@@ -33,7 +33,7 @@
.table-mobile-header{ role: 'rowheader' }= _('Projects')
.table-mobile-content
- if runner.group_type?
- = _('n/a')
+ \-
- else
= runner.runner_projects.count(:all)
@@ -64,10 +64,10 @@
= sprite_icon('pencil', css_class: 'gl-icon')
.btn-group
- if runner.active?
- = link_to pause_group_runner_path(@group, runner), method: :post, class: 'gl-button btn btn-default btn-icon has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
+ = link_to pause_group_runner_path(@group, runner), method: :post, class: 'gl-button btn btn-default btn-icon', title: s_('Runners|Pause from accepting jobs'), ref: 'tooltip', aria: { label: _('Pause') }, data: { toggle: 'tooltip', container: 'body', confirm: _('Are you sure?') } do
= sprite_icon('pause', css_class: 'gl-icon')
- else
- = link_to resume_group_runner_path(@group, runner), method: :post, class: 'gl-button btn btn-default btn-icon has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
+ = link_to resume_group_runner_path(@group, runner), method: :post, class: 'gl-button btn btn-default btn-icon', title: s_('Runners|Resume accepting jobs'), ref: 'tooltip', aria: { label: _('Resume') }, data: { toggle: 'tooltip', container: 'body'} do
= sprite_icon('play', css_class: 'gl-icon')
- if runner.belongs_to_more_than_one_project?
- delete_runner_tooltip = _('Multi-project Runners cannot be removed')
diff --git a/app/views/groups/runners/_settings.html.haml b/app/views/groups/runners/_settings.html.haml
index 55960703f9a..bbcadc08a8b 100644
--- a/app/views/groups/runners/_settings.html.haml
+++ b/app/views/groups/runners/_settings.html.haml
@@ -1,3 +1,17 @@
+- if Feature.enabled?(:runner_list_group_view_vue_ui, @group, default_enabled: :yaml)
+ .gl-card.gl-px-8.gl-py-6.gl-line-height-20
+ .gl-card-body.gl-display-flex{ :class => "gl-p-0!" }
+ .gl-banner-illustration
+ = image_tag('illustrations/rocket-launch-md.svg', alt: s_('Runners|Rocket launch illustration'))
+ .gl-banner-content
+ %h1.gl-banner-title
+ = s_('Runners|New group runners view')
+ %p
+ = s_('Runners|The new view gives you more space and better visibility into your fleet of runners.')
+ %a.btn.btn-confirm.btn-md.gl-button{ :href => group_runners_path(@group) }
+ %span.gl-button-text
+ = s_('Runners|Take me there!')
+
= render 'shared/runners/runner_description'
%hr
diff --git a/app/views/groups/runners/edit.html.haml b/app/views/groups/runners/edit.html.haml
index a0d7b8acb47..4a5bab94246 100644
--- a/app/views/groups/runners/edit.html.haml
+++ b/app/views/groups/runners/edit.html.haml
@@ -1,8 +1,14 @@
- breadcrumb_title _('Edit')
- page_title _('Edit'), "##{@runner.id} (#{@runner.short_sha})"
-- add_to_breadcrumbs _('CI/CD Settings'), group_settings_ci_cd_path(@group)
+
+- if Feature.enabled?(:runner_list_group_view_vue_ui, @group, default_enabled: :yaml)
+ - add_to_breadcrumbs _('Runners'), group_runners_path(@group)
+- else
+ - add_to_breadcrumbs _('CI/CD Settings'), group_settings_ci_cd_path(@group)
+
- add_to_breadcrumbs "#{@runner.short_sha}", group_runner_path(@group, @runner)
+
%h2.page-title
= s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
= render 'shared/runners/runner_type_badge', runner: @runner
diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml
index 5cf83e8ccfd..72701491c67 100644
--- a/app/views/groups/runners/show.html.haml
+++ b/app/views/groups/runners/show.html.haml
@@ -1,3 +1,6 @@
-- add_to_breadcrumbs _('CI/CD Settings'), group_settings_ci_cd_path(@group)
+- if Feature.enabled?(:runner_list_group_view_vue_ui, @group, default_enabled: :yaml)
+ - add_to_breadcrumbs _('Runners'), group_runners_path(@group)
+- else
+ - add_to_breadcrumbs _('CI/CD Settings'), group_settings_ci_cd_path(@group)
= render 'shared/runners/runner_details', runner: @runner
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index d4b74665398..dd62c9e118d 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -35,7 +35,6 @@
= render_if_exists 'groups/settings/ip_restriction', f: f, group: @group
= render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group
= render 'groups/settings/lfs', f: f
- = render 'groups/settings/default_branch_protection', f: f, group: @group
= render 'groups/settings/project_creation_level', f: f, group: @group
= render 'groups/settings/subgroup_creation_level', f: f, group: @group
= render_if_exists 'groups/settings/prevent_forking', f: f, group: @group
@@ -43,7 +42,7 @@
= render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group
= render 'groups/settings/membership', f: f, group: @group
- - if crm_feature_flag_enabled?(@group)
+ - if crm_feature_available?(@group)
%h5= _('Customer relations')
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :crm_enabled,
diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml
index d52d9d59ab3..dde8213b293 100644
--- a/app/views/groups/settings/_transfer.html.haml
+++ b/app/views/groups/settings/_transfer.html.haml
@@ -16,5 +16,5 @@
.gl-alert.gl-alert-info.gl-mb-5
= sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body
- = html_escape(_("This group can't be transfered because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
+ = html_escape(_("This group can't be transferred because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
.js-transfer-group-form{ data: initial_data }
diff --git a/app/views/groups/settings/repository/_initial_branch_name.html.haml b/app/views/groups/settings/repository/_default_branch.html.haml
index 15a3bacf12d..f2644465a49 100644
--- a/app/views/groups/settings/repository/_initial_branch_name.html.haml
+++ b/app/views/groups/settings/repository/_default_branch.html.haml
@@ -1,22 +1,24 @@
%section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- = _('Default initial branch name')
+ = _('Default branch')
%button.gl-button.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = s_('GroupSettings|The default name for the initial branch of new repositories created in the group.')
+ = s_('GroupSettings|Set the initial name and protections for the default branch of new repositories created in the group.')
.settings-content
- = form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
+ = gitlab_ui_form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
= form_errors(@group)
- fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value(object: @group)}</code>"
%fieldset
.form-group
- = f.label :default_branch_name, _('Default initial branch name'), class: 'label-light'
+ = f.label :default_branch_name, _('Initial default branch name'), class: 'label-light'
= f.text_field :default_branch_name, value: group.namespace_settings&.default_branch_name, placeholder: Gitlab::DefaultBranch.value(object: @group), class: 'form-control'
%span.form-text.text-muted
= (s_("GroupSettings|If not specified at the group or instance level, the default is %{default_initial_branch_name}. Does not affect existing repositories.") % { default_initial_branch_name: fallback_branch_name }).html_safe
- = f.hidden_field :redirect_target, value: "repository_settings"
- = f.submit _('Save changes'), class: 'btn gl-button btn-confirm'
+ = render 'groups/settings/default_branch_protection', f: f, group: @group
+
+ = f.hidden_field :redirect_target, value: "repository_settings"
+ = f.submit _('Save changes'), class: 'btn gl-button btn-confirm'
diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml
index a5819320405..072c8c4d821 100644
--- a/app/views/groups/settings/repository/show.html.haml
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -4,4 +4,4 @@
- deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.')
= render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description
-= render "initial_branch_name", group: @group
+= render "default_branch", group: @group
diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml
index 755c4151115..95c15612adf 100644
--- a/app/views/ide/_show.html.haml
+++ b/app/views/ide/_show.html.haml
@@ -9,5 +9,5 @@
#ide.ide-loading{ data: ide_data }
.text-center
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md')
%h2.clgray= _('Loading the GitLab IDE...')
diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml
index badd8c1278f..3e8a99c541a 100644
--- a/app/views/import/shared/_errors.html.haml
+++ b/app/views/import/shared/_errors.html.haml
@@ -1,8 +1,8 @@
- if @errors.present?
- .gl-alert.gl-alert-danger.gl-mb-5
- .gl-alert-container
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-body
- - @errors.each do |error|
- = error
+ = render 'shared/global_alert',
+ variant: :danger,
+ dismissible: false,
+ alert_class: 'gl-mb-5' do
+ .gl-alert-body
+ - @errors.each do |error|
+ = error
diff --git a/app/views/jira_connect/oauth_callbacks/index.html.haml b/app/views/jira_connect/oauth_callbacks/index.html.haml
new file mode 100644
index 00000000000..d35834bf05d
--- /dev/null
+++ b/app/views/jira_connect/oauth_callbacks/index.html.haml
@@ -0,0 +1 @@
+%p= s_('Integrations|You can close this window.')
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 5ca4a2f9888..15cd9bece71 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -38,6 +38,7 @@
= render 'layouts/startup_css', { startup_filename: local_assigns.fetch(:startup_filename, nil) }
- if user_application_theme == 'gl-dark'
+ %meta{ name: 'color-scheme', content: 'dark light' }
= stylesheet_link_tag_defer "application_dark"
= yield :page_specific_styles
= stylesheet_link_tag_defer "application_utilities_dark"
@@ -56,7 +57,7 @@
= Gon::Base.render_data(nonce: content_security_policy_nonce)
- = javascript_include_tag locale_path unless I18n.locale == :en
+ = render_if_exists 'layouts/header/translations'
= webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled
= webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
diff --git a/app/views/layouts/_header_search.html.haml b/app/views/layouts/_header_search.html.haml
new file mode 100644
index 00000000000..d2fe9a9a6ee
--- /dev/null
+++ b/app/views/layouts/_header_search.html.haml
@@ -0,0 +1,24 @@
+#js-header-search.header-search{ data: { 'search-context' => header_search_context.to_json,
+'search-path' => search_path,
+'issues-path' => issues_dashboard_path,
+'mr-path' => merge_requests_dashboard_path,
+'autocomplete-path' => search_autocomplete_path } }
+ = form_tag search_path, method: :get do |_f|
+ .gl-search-box-by-type
+ = sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon')
+ %input{ id: 'search', name: 'search', type: "text", placeholder: s_('GlobalSearch|Search GitLab'), class: 'form-control gl-form-input gl-search-box-by-type-input', autocomplete: 'off' }
+
+ = hidden_field_tag :group_id, header_search_context[:group][:id] if header_search_context[:group]
+ = hidden_field_tag :project_id, header_search_context[:project][:id] if header_search_context[:project]
+
+ - if header_search_context[:group] || header_search_context[:project]
+ = hidden_field_tag :scope, header_search_context[:scope]
+ = hidden_field_tag :search_code, header_search_context[:code_search]
+
+ = hidden_field_tag :snippets, header_search_context[:for_snippets]
+ = hidden_field_tag :repository_ref, header_search_context[:ref]
+ = hidden_field_tag :nav_source, 'navbar'
+
+ -# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb
+ - if ENV['RAILS_ENV'] == 'test'
+ %noscript= button_tag 'Search'
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index b7299df1bc1..a656b61dc8f 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -3,23 +3,22 @@
= render "layouts/nav/sidebar/#{nav}"
.content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" }
.mobile-overlay
- = render_if_exists 'layouts/header/verification_reminder'
+ = dispensable_render_if_exists 'layouts/header/verification_reminder'
.alert-wrapper.gl-force-block-formatting-context
- = render 'shared/outdated_browser'
- = render_if_exists "layouts/header/licensed_user_count_threshold"
- = render_if_exists "layouts/header/token_expiry_notification"
- = render "layouts/broadcast"
- = render "layouts/header/read_only_banner"
- = render "layouts/header/registration_enabled_callout"
- = render "layouts/nav/classification_level_banner"
+ = dispensable_render 'shared/outdated_browser'
+ = dispensable_render_if_exists "layouts/header/licensed_user_count_threshold"
+ = dispensable_render_if_exists "layouts/header/token_expiry_notification"
+ = dispensable_render "layouts/broadcast"
+ = dispensable_render "layouts/header/read_only_banner"
+ = dispensable_render "layouts/header/registration_enabled_callout"
+ = dispensable_render "layouts/nav/classification_level_banner"
= yield :flash_message
- = render "shared/service_ping_consent"
- = render_two_factor_auth_recovery_settings_check
- = render_if_exists "layouts/header/ee_subscribable_banner"
- = render_if_exists "layouts/header/seats_count_alert"
- = render_if_exists "shared/namespace_storage_limit_alert"
- = render_if_exists "shared/namespace_user_cap_reached_alert"
- = render_if_exists "shared/new_user_signups_cap_reached_alert"
+ = dispensable_render "shared/service_ping_consent"
+ = dispensable_render_if_exists "layouts/header/ee_subscribable_banner"
+ = dispensable_render_if_exists "layouts/header/seats_count_alert"
+ = dispensable_render_if_exists "shared/namespace_storage_limit_alert"
+ = dispensable_render_if_exists "shared/namespace_user_cap_reached_alert"
+ = dispensable_render_if_exists "shared/new_user_signups_cap_reached_alert"
= yield :page_level_alert
= yield :group_invite_members_banner
- unless @hide_breadcrumbs
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 58fed89dfe7..940724e0e4a 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -6,6 +6,9 @@
- display_namespace_storage_limit_alert!
- @left_sidebar = true
+- content_for :flash_message do
+ = render "layouts/header/storage_enforcement_banner", namespace: @group
+
- content_for :page_specific_javascripts do
- if current_user
= javascript_tag do
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 871d1213c0e..512a4185bee 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -38,17 +38,10 @@
= render 'layouts/header/new_dropdown', class: 'gl-display-none gl-sm-display-block'
- if top_nav_show_search
- search_menu_item = top_nav_search_menu_item_attrs
- %li.nav-item.d-none.d-lg-block.m-auto
+ %li.nav-item.header-search-new.d-none.d-lg-block.m-auto
- unless current_controller?(:search)
- if Feature.enabled?(:new_header_search)
- #js-header-search.header-search{ data: { 'search-context' => header_search_context.to_json,
- 'search-path' => search_path,
- 'issues-path' => issues_dashboard_path,
- 'mr-path' => merge_requests_dashboard_path,
- 'autocomplete-path' => search_autocomplete_path } }
- .gl-search-box-by-type
- = sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon')
- %input{ type: "text", placeholder: s_('GlobalSearch|Search GitLab'), class: 'form-control gl-form-input gl-search-box-by-type-input', id: 'search', autocomplete: 'off' }
+ = render 'layouts/header_search'
- else
= render 'layouts/search'
%li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' }
@@ -68,7 +61,8 @@
= number_with_delimiter(issues_count)
- if header_link?(:merge_requests)
= nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter dropdown" }) do
- = link_to assigned_mrs_dashboard_path, class: 'dashboard-shortcuts-merge_requests', title: _('Merge requests'), aria: { label: _('Merge requests') },
+ - top_level_link = Feature.enabled?(:mr_attention_requests, default_enabled: :yaml) ? attention_requested_mrs_dashboard_path : assigned_mrs_dashboard_path
+ = link_to top_level_link, class: 'dashboard-shortcuts-merge_requests', title: _('Merge requests'), aria: { label: _('Merge requests') },
data: { qa_selector: 'merge_requests_shortcut_button',
toggle: "dropdown",
placement: 'bottom',
@@ -84,6 +78,13 @@
%ul
%li.dropdown-header
= _('Merge requests')
+ - if Feature.enabled?(:mr_attention_requests, default_enabled: :yaml)
+ %li#js-need-attention-nav
+ #js-need-attention-nav-onboarding
+ = link_to attention_requested_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
+ = _('Need your attention')
+ = gl_badge_tag user_merge_requests_counts[:attention_requested_count], { size: :sm, variant: user_merge_requests_counts[:attention_requested_count] == 0 ? :neutral : :warning }, { class: 'merge-request-badge gl-ml-auto js-attention-count' }
+ %li.divider
%li
= link_to assigned_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
= _('Assigned to you')
diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml
index 90f3ac61614..d1d23c86c81 100644
--- a/app/views/layouts/header/_registration_enabled_callout.html.haml
+++ b/app/views/layouts/header/_registration_enabled_callout.html.haml
@@ -1,14 +1,17 @@
- return unless show_registration_enabled_user_callout?
= render 'shared/global_alert',
- title: _('Open registration is enabled on your instance.'),
+ title: _('Anyone can register for an account.'),
variant: :warning,
alert_class: 'js-registration-enabled-callout',
alert_data: { feature_id: Users::CalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: callouts_path },
close_button_data: { testid: 'close-registration-enabled-callout' } do
.gl-alert-body
- = html_escape(_('%{anchorOpen}Learn more%{anchorClose} about how you can customize / disable registration on your instance.')) % { anchorOpen: "<a href=\"#{help_page_path('user/admin_area/settings/sign_up_restrictions')}\" class=\"gl-link\">".html_safe, anchorClose: '</a>'.html_safe }
+ = _('Only allow anyone to register for accounts on GitLab instances that you intend to be used by anyone. Allowing anyone to register makes GitLab instances more vulnerable.')
.gl-alert-actions
= link_to general_admin_application_settings_path(anchor: 'js-signup-settings'), class: 'btn gl-alert-action btn-confirm btn-md gl-button' do
%span.gl-button-text
- = _('View setting')
+ = _('Turn off')
+ %button.btn.gl-alert-action.btn-default.btn-md.gl-button.js-close
+ %span.gl-button-text
+ = _('Acknowledge')
diff --git a/app/views/layouts/header/_storage_enforcement_banner.html.haml b/app/views/layouts/header/_storage_enforcement_banner.html.haml
new file mode 100644
index 00000000000..851fc57e44d
--- /dev/null
+++ b/app/views/layouts/header/_storage_enforcement_banner.html.haml
@@ -0,0 +1,9 @@
+- return unless current_user
+- namespace = local_assigns.fetch(:namespace)
+- banner_info = storage_enforcement_banner_info(namespace)
+- return unless banner_info.present?
+
+= render 'shared/global_alert', variant: :warning, alert_class: 'js-storage-enforcement-banner', alert_data: { feature_id: banner_info[:callouts_feature_name], dismiss_endpoint: banner_info[:callouts_path], group_id: namespace.id, defer_links: "true" } do
+ .gl-alert-body
+ = banner_info[:text]
+ = banner_info[:learn_more_link]
diff --git a/app/views/layouts/header/_translations.html.haml b/app/views/layouts/header/_translations.html.haml
new file mode 100644
index 00000000000..979f39ad3e0
--- /dev/null
+++ b/app/views/layouts/header/_translations.html.haml
@@ -0,0 +1 @@
+= javascript_include_tag locale_path unless I18n.locale == :en
diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml
index e922b505be8..3b979f69cac 100644
--- a/app/views/layouts/notify.html.haml
+++ b/app/views/layouts/notify.html.haml
@@ -3,7 +3,10 @@
%meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
%title
GitLab
- = stylesheet_link_tag 'notify'
+ - if Feature.enabled?(:enhanced_notify_css)
+ = stylesheet_link_tag 'notify_enhanced'
+ - else
+ = stylesheet_link_tag 'notify'
= yield :head
%body
.content
diff --git a/app/views/layouts/profile.html.haml b/app/views/layouts/profile.html.haml
index 17153e72e6e..322a77116c8 100644
--- a/app/views/layouts/profile.html.haml
+++ b/app/views/layouts/profile.html.haml
@@ -5,4 +5,8 @@
- @left_sidebar = true
- enable_search_settings locals: { container_class: 'gl-my-5' }
+
+- content_for :flash_message do
+ = render "layouts/header/storage_enforcement_banner", namespace: current_user.namespace
+
= render template: "layouts/application"
diff --git a/app/views/layouts/service_desk.html.haml b/app/views/layouts/service_desk.html.haml
index 26d15a74403..a838ba91d26 100644
--- a/app/views/layouts/service_desk.html.haml
+++ b/app/views/layouts/service_desk.html.haml
@@ -5,7 +5,10 @@
%title
GitLab
-# haml-lint:enable NoPlainNodes
- = stylesheet_link_tag 'notify'
+ - if Feature.enabled?(:enhanced_notify_css)
+ = stylesheet_link_tag 'notify_enhanced'
+ - else
+ = stylesheet_link_tag 'notify'
= yield :head
%body
.content
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
index ad0c873bf56..55984472047 100644
--- a/app/views/notify/_note_email.html.haml
+++ b/app/views/notify/_note_email.html.haml
@@ -25,11 +25,11 @@
= content_for :head do
= stylesheet_link_tag 'mailers/highlighted_diff_email'
- %table
+ %table.code
= render partial: "projects/diffs/email_line",
collection: discussion.truncated_diff_lines(diff_limit: diff_limit),
as: :line,
locals: { diff_file: discussion.diff_file }
-%div{ style: note_style }
+.md{ style: note_style }
= markdown(note.note, pipeline: :email, author: note.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
diff --git a/app/views/notify/access_token_created_email.html.haml b/app/views/notify/access_token_created_email.html.haml
new file mode 100644
index 00000000000..9eea8f44142
--- /dev/null
+++ b/app/views/notify/access_token_created_email.html.haml
@@ -0,0 +1,7 @@
+%p
+ = _('Hi %{username}!') % { username: sanitize_name(@user.name) }
+%p
+ = html_escape(_('A new personal access token, named %{token_name}, has been created.')) % { token_name: @token_name }
+%p
+ - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
+ = html_escape(_('You can check it in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }
diff --git a/app/views/notify/access_token_created_email.text.erb b/app/views/notify/access_token_created_email.text.erb
new file mode 100644
index 00000000000..caf01410de6
--- /dev/null
+++ b/app/views/notify/access_token_created_email.text.erb
@@ -0,0 +1,5 @@
+<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
+
+<%= _('A new personal access token, named %{token_name}, has been created.') % { token_name: @token_name } %>
+
+<%= _('You can check it in your in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %>
diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml
index c9cd9c32b54..e512d7732e2 100644
--- a/app/views/notify/issue_due_email.html.haml
+++ b/app/views/notify/issue_due_email.html.haml
@@ -8,5 +8,5 @@
This issue is due on: #{@issue.due_date.to_s(:medium)}
- if @issue.description
- %div
- = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
+ .md
+ = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index 439604a950a..592b3f453af 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -7,5 +7,5 @@
= assignees_label(@issue)
- if @issue.description
- %div
- = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
+ .md
+ = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index 54fb6573c26..f67ac5f8fb2 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -15,5 +15,5 @@
= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter
- if @merge_request.description
- %div
+ .md
= markdown(@merge_request.description, pipeline: :email, author: @merge_request.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
diff --git a/app/views/notify/new_release_email.html.haml b/app/views/notify/new_release_email.html.haml
index 1cd3a2340c6..09c0e7a8abd 100644
--- a/app/views/notify/new_release_email.html.haml
+++ b/app/views/notify/new_release_email.html.haml
@@ -1,7 +1,7 @@
- release_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
- description_details = { tag: @release.tag, name: @project.name, release_link_start: release_link_start, release_link_end: '</a>'.html_safe }
-%div{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+.md{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
%p
= _("A new Release %{tag} for %{name} was published. Visit the %{release_link_start}Releases page%{release_link_end} to read more about it.").html_safe % description_details
diff --git a/app/views/notify/service_desk_new_note_email.html.haml b/app/views/notify/service_desk_new_note_email.html.haml
index 186bdf133e3..0c16cf3315f 100644
--- a/app/views/notify/service_desk_new_note_email.html.haml
+++ b/app/views/notify/service_desk_new_note_email.html.haml
@@ -1,5 +1,5 @@
- if Gitlab::CurrentSettings.email_author_in_body
%div
= _("%{author_link} wrote:").html_safe % { author_link: link_to(@note.author_name, user_url(@note.author)) }
-%div
+.md
= markdown(@note.note, pipeline: :email, author: @note.author, issuable_reference_expansion_enabled: true)
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index 97056db6b74..fdcee3670b7 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -5,7 +5,6 @@
= render 'shared/global_alert',
variant: :info,
alert_class: 'gl-my-5',
- is_contained: true,
dismissible: false do
.gl-alert-body
= s_('Profiles|Some options are unavailable for LDAP accounts')
@@ -14,7 +13,6 @@
= render 'shared/global_alert',
variant: :success,
alert_class: 'gl-my-5',
- is_contained: true,
close_button_class: 'js-close-2fa-enabled-success-alert' do
.gl-alert-body
= html_escape(_('You have set up 2FA for your account! If you lose access to your 2FA device, you can use your recovery codes to access your account. Alternatively, if you upload an SSH key, you can %{anchorOpen}use that key to generate additional recovery codes%{anchorClose}.')) % { anchorOpen: '<a href="%{href}">'.html_safe % { href: help_page_path('user/profile/account/two_factor_authentication', anchor: 'generate-new-recovery-codes-using-ssh') }, anchorClose: '</a>'.html_safe }
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index 3206fca6bcd..8f80c9fdc6c 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -24,4 +24,4 @@
= _('Never')
%td
- = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'gl-button btn btn-danger float-right', data: { confirm: _('Are you sure you want to revoke this nickname?') }
+ = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'gl-button btn btn-danger float-right', aria: { label: _('Remove') }, data: { confirm: _('Are you sure you want to remove this nickname?'), confirm_btn_variant: 'danger' }
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index aae6212f964..5f8b21b2646 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -40,12 +40,10 @@
= _('Time based: Yes')
= form_tag profile_two_factor_auth_path, method: :post do |f|
- if @error
- .gl-alert.gl-alert-danger.gl-mb-5
- .gl-alert-container
- .gl-alert-content
- %p.gl-alert-body.gl-md-0
- = @error[:message]
- = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'
+ = render 'shared/global_alert', title: @error[:message], variant: :danger, dismissible: false do
+ .gl-alert-body
+ = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'
+
.form-group
= label_tag :pin_code, _('Pin code'), class: "label-bold"
= text_field_tag :pin_code, nil, class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' }
@@ -113,7 +111,7 @@
%span.gl-text-gray-500
= _("no name set")
%td= registration[:created_at].to_date.to_s(:medium)
- %td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "gl-button btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') }
+ %td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "gl-button btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.'), confirm_btn_variant: "danger" }, aria: { label: _('Delete') }
- else
.settings-message.text-center
diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml
index c5a0b6a1428..05166395067 100644
--- a/app/views/projects/_activity.html.haml
+++ b/app/views/projects/_activity.html.haml
@@ -11,4 +11,4 @@
.content_list.project-activity{ :"data-href" => activity_project_path(@project) }
.loading
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md')
diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index 2c18921d874..d987c4b1033 100644
--- a/app/views/projects/_commit_button.html.haml
+++ b/app/views/projects/_commit_button.html.haml
@@ -2,6 +2,6 @@
= button_tag 'Commit changes', id: 'commit-changes', class: 'gl-button btn btn-confirm js-commit-button qa-commit-button'
= link_to _('Cancel'), cancel_path,
- class: 'gl-button btn btn-default gl-ml-3', data: {confirm: leave_edit_message}
+ id: 'cancel-changes', class: 'gl-button btn btn-default gl-ml-3', data: {confirm: leave_edit_message, confirm_btn_variant: "danger"}, aria: { label: _('Discard changes') }
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/_deletion_failed.html.haml b/app/views/projects/_deletion_failed.html.haml
index 21c799f5bb6..b713b805009 100644
--- a/app/views/projects/_deletion_failed.html.haml
+++ b/app/views/projects/_deletion_failed.html.haml
@@ -1,10 +1,7 @@
- project = local_assigns.fetch(:project)
- return unless project.delete_error.present?
-.project-deletion-failed-message.gl-alert.gl-alert-warning
- .gl-alert-container
- = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-body
- This project was scheduled for deletion, but failed with the following message:
- = project.delete_error
+= render 'shared/global_alert', variant: :warning, dismissible: false, alert_class: 'project-deletion-failed-message' do
+ .gl-alert-body
+ This project was scheduled for deletion, but failed with the following message:
+ = project.delete_error
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 2f4a61865f8..a7cf50623f0 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -12,8 +12,7 @@
.info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column
#js-last-commit.gl-m-auto
- .gl-spinner-container.m-auto
- = loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom')
+ = gl_loading_icon(size: 'md')
#js-code-owners
- if is_project_overview
diff --git a/app/views/projects/_gitlab_import_modal.html.haml b/app/views/projects/_gitlab_import_modal.html.haml
deleted file mode 100644
index 689e100ab96..00000000000
--- a/app/views/projects/_gitlab_import_modal.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-#gitlab_import_modal.modal
- .modal-dialog
- .modal-content
- .modal-header
- %h3.modal-title Import projects from GitLab.com
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- %span{ "aria-hidden": "true" } &times;
- .modal-body
- To enable importing projects from GitLab.com,
- - if current_user.admin?
- as administrator you need to configure
- - else
- ask your GitLab administrator to configure
- = link_to 'OAuth integration', help_page_path("integration/gitlab")
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index aca7b73267b..a8b809d1871 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -8,7 +8,7 @@
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body', qa_selector: 'gitlab_import_button' } }
- = link_to new_import_gitlab_project_path, class: 'gl-button btn-default btn btn_import_gitlab_project js-import-project-btn', data: { platform: 'gitlab_export', **tracking_attrs_data(track_label, 'click_button', 'gitlab_export') } do
+ = link_to '#', class: 'gl-button btn-default btn btn_import_gitlab_project js-import-project-btn', data: { href: new_import_gitlab_project_path, platform: 'gitlab_export', **tracking_attrs_data(track_label, 'click_button', 'gitlab_export') } do
.gl-button-icon
= sprite_icon('tanuki')
= _("GitLab export")
@@ -36,12 +36,11 @@
%div
- if gitlab_import_enabled?
%div
- = link_to status_import_gitlab_path, class: "gl-button btn-default btn import_gitlab js-import-project-btn #{'how_to_import_link' unless gitlab_import_configured?}", data: { platform: 'gitlab_com', **tracking_attrs_data(track_label, 'click_button', 'gitlab_com') } do
+ = link_to status_import_gitlab_path, class: "gl-button btn-default btn import_gitlab js-import-project-btn #{'js-how-to-import-link' unless gitlab_import_configured?}",
+ data: { modal_title: _("Import projects from GitLab.com"), modal_message: import_from_gitlab_message, platform: 'gitlab_com', **tracking_attrs_data(track_label, 'click_button', 'gitlab_com') } do
.gl-button-icon
= sprite_icon('tanuki')
= _("GitLab.com")
- - unless gitlab_import_configured?
- = render 'projects/gitlab_import_modal'
- if fogbugz_import_enabled?
%div
diff --git a/app/views/projects/_invite_groups_modal.html.haml b/app/views/projects/_invite_groups_modal.html.haml
index d16e87d1c26..40dc0009b24 100644
--- a/app/views/projects/_invite_groups_modal.html.haml
+++ b/app/views/projects/_invite_groups_modal.html.haml
@@ -1,3 +1,3 @@
-- return unless can_admin_project_member?(project)
+- return unless can_invite_members_for_project?(project)
.js-invite-groups-modal{ data: common_invite_group_modal_data(project, ProjectMember, 'true') }
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index 9ba7d25b662..88cce9e71c0 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -1,9 +1,9 @@
- event = last_push_event
- if event && show_last_push_widget?(event)
- .gl-alert.gl-alert-success.mt-2{ role: 'alert' }
- = sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
- = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ = render 'shared/global_alert',
+ variant: :success,
+ alert_class: 'gl-mt-3',
+ close_button_class: 'js-close-banner' do
.gl-alert-body
%span= s_("LastPushEvent|You pushed to")
%strong.gl-display-inline-flex.gl-max-w-50p{ data: { toggle: 'tooltip' }, title: event.ref_name }
diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml
index 778586a592e..250f7e94e84 100644
--- a/app/views/projects/_merge_request_merge_method_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_method_settings.html.haml
@@ -19,9 +19,9 @@
.text-secondary
= s_('ProjectSettings|Every merge creates a merge commit.')
%br
- = s_('ProjectSettings|Fast-forward merges only.')
+ = s_('ProjectSettings|Merging is only allowed when the source branch is up-to-date with its target.')
%br
- = s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.')
+ = s_('ProjectSettings|When semi-linear merge is not possible, the user is given the option to rebase.')
.form-check.mb-2
= form.radio_button :merge_method, :ff, class: "js-merge-method-radio form-check-input", data: { qa_selector: 'merge_ff_radio' }
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 966587a9210..1fb045544aa 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -8,7 +8,7 @@
.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", data: { track_label: "#{track_label}", track_action: "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: { qa_selector: 'project_name', 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= _('Project URL')
@@ -29,14 +29,13 @@
.form-group.project-path.col-sm-6
= f.label :path, class: 'label-bold' do
%span= _("Project slug")
- = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { username: current_user.username }
+ = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { qa_selector: 'project_path', username: current_user.username }
- if current_user.can_create_group?
.form-text.text-muted
- link_start_group_path = '<a href="%{path}">' % { path: new_group_path }
- project_tip = s_('ProjectsNew|Want to house several dependent projects under the same namespace? %{link_start}Create a group.%{link_end}') % { link_start: link_start_group_path, link_end: '</a>' }
= project_tip.html_safe
-.gl-alert.gl-alert-success.gl-mb-4.gl-display-none.js-user-readme-repo
- = sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+= render 'shared/global_alert', alert_class: "gl-mb-4 gl-display-none js-user-readme-repo", dismissible: false, variant: :success do
.gl-alert-body
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'add-details-to-your-profile-with-a-readme') }
= html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
@@ -44,14 +43,15 @@
.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_action: "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: { qa_selector: 'project_description', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_description", track_value: "" }
-.js-deployment-target-select
+- unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers?
+ .js-deployment-target-select
= f.label :visibility_level, class: 'label-bold' do
= s_('ProjectsNew|Visibility Level')
= link_to sprite_icon('question-o'), help_page_path('public_access/public_access'), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer'
-= render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false
+= render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false, data: { qa_selector: 'visibility_radios'}
- if !hide_init_with_readme
= f.label :project_configuration, class: 'label-bold' do
@@ -74,5 +74,5 @@
- e.variant(:unchecked_free_indicator) do
= render 'new_project_initialize_with_sast', experiment_name: e.name, track_label: track_label, checked: false, with_free_badge: true
-= 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: "" }
+= f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { qa_selector: 'project_create_button', 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/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml
index 68489fba06c..d00ed2afa3c 100644
--- a/app/views/projects/_project_templates.html.haml
+++ b/app/views/projects/_project_templates.html.haml
@@ -1,11 +1,10 @@
- f ||= local_assigns[:f]
.project-templates-buttons
- %ul.nav-tabs.nav-links.nav.scrolling-tabs
- %li.built-in-tab
- %a.nav-link.active{ href: "#built-in", data: { toggle: 'tab'} }
- = _('Built-in')
- = gl_tab_counter_badge Gitlab::ProjectTemplate.all.count + Gitlab::SampleDataTemplate.all.count
+ = gl_tabs_nav({ class: 'nav-links scrolling-tabs gl-display-flex gl-flex-grow-1 gl-flex-nowrap gl-border-0' }) do
+ = gl_tab_link_to '#built-in', tab_class: 'built-in-tab', class: 'active', data: { toggle: 'tab' } do
+ = _('Built-in')
+ = gl_tab_counter_badge Gitlab::ProjectTemplate.all.count + Gitlab::SampleDataTemplate.all.count
.tab-content
.project-templates-buttons.import-buttons.tab-pane.active#built-in
diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml
index 229de2f759c..9e548582396 100644
--- a/app/views/projects/artifacts/_artifact.html.haml
+++ b/app/views/projects/artifacts/_artifact.html.haml
@@ -57,5 +57,5 @@
= sprite_icon('folder-open', css_class: 'gl-icon')
- if can?(current_user, :destroy_artifacts, @project)
- = link_to project_artifact_path(@project, artifact), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?') }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'gl-button btn btn-danger btn-icon has-tooltip' do
+ = link_to project_artifact_path(@project, artifact), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?'), confirm_btn_variant: "danger" }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'gl-button btn btn-danger btn-icon has-tooltip' do
= sprite_icon('remove', css_class: 'gl-icon')
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index 919cafe7ce8..85b9a69ab4c 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -21,8 +21,7 @@
project_path: @project.full_path,
target_branch: project.empty_repo? ? ref : @ref,
original_branch: @ref } }
- .gl-spinner-container
- = loading_icon(size: 'md')
+ = gl_loading_icon(size: 'md')
- else
%article.file-holder
= render 'projects/blob/header', blob: blob
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 41333c416de..c9303e19d5d 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -45,5 +45,4 @@
- if local_assigns[:path]
.js-edit-mode-pane#preview.hide
.center
- %h2
- %i.icon-spinner.icon-spin
+ = gl_loading_icon(size: 'lg')
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index 74df53a8d15..8260aa0fb7e 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -5,13 +5,7 @@
.file-actions.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-md-justify-content-end<
= render 'projects/blob/viewer_switcher', blob: blob unless blame
- - if Feature.enabled?(:consolidated_edit_button, @project)
- = render 'shared/web_ide_button', blob: blob
- - else
- = edit_blob_button(@project, @ref, @path, blob: blob)
- = ide_edit_button(@project, @ref, @path, blob: blob)
- - if can_view_pipeline_editor?(@project) && @path == @project.ci_config_path_or_default
- = link_to "Pipeline Editor", project_ci_pipeline_editor_path(@project, branch_name: @ref), class: "btn gl-button btn-confirm-secondary gl-ml-3"
+ = render 'shared/web_ide_button', blob: blob
.btn-group{ role: "group", class: ("gl-ml-3" if current_user) }>
= render_if_exists 'projects/blob/header_file_locks_link'
- if current_user
diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml
index 6d2751bb7d4..1d3bec1ad44 100644
--- a/app/views/projects/blob/_upload.html.haml
+++ b/app/views/projects/blob/_upload.html.haml
@@ -20,8 +20,8 @@
= render 'shared/new_commit_form', placeholder: placeholder, ref: local_assigns[:ref]
.form-actions
- = button_tag class: 'btn gl-button btn-confirm btn-upload-file', id: 'submit-all', type: 'button' do
- .gl-spinner.gl-mr-2.js-loading-icon.hidden
+ = button_tag class: 'btn gl-button btn-confirm btn-upload-file gl-mr-2', id: 'submit-all', type: 'button' do
+ = gl_loading_icon(inline: true, css_class: 'gl-mr-2 js-loading-icon hidden')
= button_title
= link_to _("Cancel"), '#', class: "btn gl-button btn-default btn-cancel", "data-dismiss" => "modal"
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 8378ce2c7e5..773137ff3f2 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -4,14 +4,16 @@
- webpack_preload_asset_tag('monaco')
- if @conflict
- .gl-alert.gl-alert-danger.gl-mb-5.gl-mt-5
- .gl-alert-container
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-body
- Someone edited the file the same time you did. Please check out
- = link_to _('the file'), project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer', class: 'gl-link'
- and make sure your changes will not unintentionally remove theirs.
+ = render 'shared/global_alert',
+ alert_class: 'gl-mb-5 gl-mt-5',
+ variant: :danger,
+ dismissible: false do
+ - blob_url = project_blob_path(@project, @id)
+ - external_link_icon = content_tag 'span', { aria: { label: _('Opens new window') }} do
+ - sprite_icon('external-link', css_class: 'gl-icon').html_safe
+ - blob_link_start = '<a href="%{url}" class="gl-link" target="_blank" rel="noopener noreferrer">'.html_safe % { url: blob_url }
+ = _('Someone edited the file the same time you did. Please check out %{link_start}the file %{icon}%{link_end} and make sure your changes will not unintentionally remove theirs.').html_safe % { link_start: blob_link_start, link_end: '</a>'.html_safe , icon: external_link_icon }
+
%h3.page-title.blob-edit-page-title
Edit file
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index 2aeffa88c8f..60877db581f 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -1,16 +1,13 @@
- breadcrumb_title _("Repository")
- page_title _("New File"), @path.presence, @ref
-%h3.page-title.blob-new-page-title#js-code-quality-walkthrough
+%h3.page-title.blob-new-page-title
= _('New file')
- .js-code-quality-walkthrough{ data: { step: 'commit_ci_file' } }
.file-editor
= form_tag(project_create_blob_path(@project, @id), method: :post, class: 'js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths(@project)) do
= render 'projects/blob/editor', ref: @ref
= render 'shared/new_commit_form', placeholder: "Add new file"
- - if params[:code_quality_walkthrough]
- = hidden_field_tag 'code_quality_walkthrough', 'true'
= hidden_field_tag 'content', '', id: 'file-content'
= render 'projects/commit_button', ref: @ref,
cancel_path: project_tree_path(@project, @id)
diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml
index cf57f1b531d..2b8f62d98bf 100644
--- a/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml
+++ b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml
@@ -1,4 +1,4 @@
-= loading_icon(css_class: "gl-vertical-align-text-bottom mr-1")
+= gl_loading_icon(inline: true, css_class: "gl-mr-2!")
= s_('Pipelines|Validating GitLab CI configuration…')
= link_to _('Learn more'), help_page_path('ci/yaml/index')
diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml
index 18fd0d87ce6..9cb934da7c0 100644
--- a/app/views/projects/blob/viewers/_loading.html.haml
+++ b/app/views/projects/blob/viewers/_loading.html.haml
@@ -1,2 +1 @@
-.text-center.gl-mt-4.gl-mb-3
- = loading_icon(size: "md", css_class: "qa-spinner")
+= gl_loading_icon(size: "md", css_class: "qa-spinner gl-my-4")
diff --git a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml
index 5a2212e0b4e..19aa96a9302 100644
--- a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml
+++ b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml
@@ -1,2 +1,2 @@
-= loading_icon(css_class: "gl-vertical-align-text-bottom")
+= gl_loading_icon(inline: true)
= _("Analyzing file…")
diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml
index db4b04eaeb8..5e355ecc4b8 100644
--- a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml
+++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml
@@ -1,4 +1,4 @@
-= loading_icon(css_class: "gl-vertical-align-text-bottom mr-1")
+= gl_loading_icon(inline: true, css_class: "mr-1")
= _('Metrics Dashboard YAML definition') + '…'
= link_to _('Learn more'), help_page_path('operations/metrics/dashboards/yaml.md')
diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml
index c48ab84654f..d9e965246a8 100644
--- a/app/views/projects/blob/viewers/_route_map_loading.html.haml
+++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml
@@ -1,4 +1,4 @@
-= loading_icon(css_class: "gl-vertical-align-text-bottom gl-mr-1")
+= gl_loading_icon(inline: true, css_class: "gl-mr-1")
Validating Route Map…
= link_to 'Learn more', help_page_path('ci/environments/index.md', anchor: 'go-from-source-files-to-public-pages')
diff --git a/app/views/projects/blob/viewers/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml
index 08c21258d3f..4feaa7392fd 100644
--- a/app/views/projects/blob/viewers/_sketch.html.haml
+++ b/app/views/projects/blob/viewers/_sketch.html.haml
@@ -1,3 +1,2 @@
.file-content#js-sketch-viewer{ data: { endpoint: blob_raw_path } }
- .text-center.gl-mt-4.gl-mb-3.js-loading-icon
- = loading_icon(size: "md")
+ = gl_loading_icon(size: "md", css_class: "gl-my-4 js-loading-icon")
diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml
index f98deebacf9..8bf0339fc3c 100644
--- a/app/views/projects/blob/viewers/_stl.html.haml
+++ b/app/views/projects/blob/viewers/_stl.html.haml
@@ -1,6 +1,6 @@
.file-content.is-stl-loading
.text-center#js-stl-viewer{ data: { endpoint: blob_raw_path } }
- = loading_icon(size: "md", css_class: "gl-mt-4 gl-mb-3")
+ = gl_loading_icon(size: "md", css_class: "gl-my-4")
.text-center.gl-mt-3.gl-mb-3.stl-controls
.btn-group
%button.gl-button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } }
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 8ee7910de4b..5cc83111b34 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -4,8 +4,7 @@
- if @error
= render 'shared/global_alert',
variant: :danger,
- close_button_class: 'js-close',
- is_contained: true do
+ close_button_class: 'js-close' do
.gl-alert-body
= @error
%h3.page-title
diff --git a/app/views/projects/ci/secure_files/show.html.haml b/app/views/projects/ci/secure_files/show.html.haml
new file mode 100644
index 00000000000..db0734be6bd
--- /dev/null
+++ b/app/views/projects/ci/secure_files/show.html.haml
@@ -0,0 +1,5 @@
+- @content_class = "limit-container-width"
+
+- page_title s_('Secure Files')
+
+#js-ci-secure-files{ data: { project_id: @project.id } }
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 36d3520cb59..a3343aa4228 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -37,13 +37,13 @@
- @commit.parents.each do |parent|
= link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha"
.commit-info.branches
- .gl-spinner.vertical-align-middle
+ = gl_loading_icon(inline: true, css_class: 'gl-vertical-align-middle')
.well-segment.merge-request-info
.icon-container
= custom_icon('mr_bold')
%span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) }
- .gl-spinner.vertical-align-middle
+ = gl_loading_icon(inline: true, css_class: 'gl-vertical-align-middle')
- if can?(current_user, :read_pipeline, @last_pipeline)
.well-segment.pipeline-info
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 9e0dd93c683..02b5fe00ad0 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -22,7 +22,7 @@
- if context_commits.present?
%li.commit-header.js-commit-header
%span.font-weight-bold= n_("%d previously merged commit", "%d previously merged commits", context_commits.count) % context_commits.count
- - if project.context_commits_enabled? && can_update_merge_request
+ - if can_update_merge_request
%button.gl-button.btn.btn-default.ml-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'false' } }
= _('Add/remove')
@@ -34,13 +34,14 @@
= render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request }
- if hidden > 0
- %li.gl-alert.gl-alert-warning
- .gl-alert-container
- = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
+ %li
+ = render 'shared/global_alert',
+ variant: :warning,
+ dismissible: false do
+ .gl-alert-body
= n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
-- if project.context_commits_enabled? && can_update_merge_request && context_commits&.empty?
+- if can_update_merge_request && context_commits&.empty?
%button.gl-button.btn.btn-default.mt-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'true' } }
= _('Add previously merged commits')
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 22a5bada311..36641a8c508 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -34,5 +34,4 @@
%div{ id: dom_id(@project) }
%ol#commits-list.list-unstyled.content_list
= render 'commits', project: @project, ref: @ref
- .loading.hide
- = loading_icon(size: "lg")
+ = gl_loading_icon(size: 'lg', css_class: 'loading hide')
diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml
index 718f129cba8..23f9afe8352 100644
--- a/app/views/projects/diffs/_content.html.haml
+++ b/app/views/projects/diffs/_content.html.haml
@@ -1,3 +1,12 @@
- diff_file = local_assigns.fetch(:diff_file, nil)
+- file_hash = hexdigest(diff_file.file_path)
+
.diff-content
- = render 'projects/diffs/viewer', viewer: diff_file.viewer
+ - if diff_file.has_renderable?
+ %div{ id: "#raw-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'toHide' } }
+ = render 'projects/diffs/viewer', viewer: diff_file.viewer
+ %div{ id: "#rendered-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'toShow' } }
+ = render 'projects/diffs/viewer', viewer: diff_file.rendered.viewer
+ - else
+ = render 'projects/diffs/viewer', viewer: diff_file.viewer
+
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 418a65118f5..0638481d968 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -25,6 +25,11 @@
= edit_blob_button(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
blob: diff_file.blob, link_opts: link_opts)
+ - if diff_file.has_renderable?
+ .btn-group.gl-ml-3
+ = diff_mode_swap_button('rendered', file_hash)
+ = diff_mode_swap_button('raw', file_hash)
+
- if image_diff && image_replaced
= view_file_button(diff_file.old_content_sha, diff_file.old_path, project, replaced: true)
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index 330e2f564c9..a5d3328b439 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -3,7 +3,7 @@
- plain = local_assigns.fetch(:plain, false)
- discussions = local_assigns.fetch(:discussions, nil)
- line_code = diff_file.line_code(line)
-- if discussions && line.discussable?
+- if discussions
- line_discussions = discussions[line_code]
%tr.line_holder{ class: line.type, id: (line_code unless plain) }
@@ -15,11 +15,12 @@
%td.new_line.diff-line-num
%td.line_content.match= line.text
- else
- %td.old_line.diff-line-num{ class: [line.type, ("js-avatar-container" if !plain)], data: { linenumber: line.old_pos } }
+ %td.old_line.diff-line-num{ class: [line.type, ("js-avatar-container" unless plain)], data: { linenumber: line.old_pos } }
- if plain
= diff_link_number(line.type, "new", line.old_pos)
- else
- = add_diff_note_button(line_code, diff_file.position(line), line.type)
+ - if line.discussable?
+ = add_diff_note_button(line_code, diff_file.position(line), line.type)
%a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "new", line.old_pos) } }
%td.new_line.diff-line-num{ class: line.type, data: { linenumber: line.new_pos } }
diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml
index 1d9b1b13d5c..3d31773694f 100644
--- a/app/views/projects/diffs/_warning.html.haml
+++ b/app/views/projects/diffs/_warning.html.haml
@@ -1,7 +1,6 @@
= render 'shared/global_alert',
title: _('Too many changes to show.'),
variant: :warning,
- is_contained: true,
alert_class: 'gl-mb-5' do
.gl-alert-body
= html_escape(_("To preserve performance only %{strong_open}%{display_size} of %{real_size}%{strong_close} files are displayed.")) % { display_size: diff_files.size, real_size: diff_files.real_size, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index f32514141c5..1609d81c0fd 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -66,6 +66,8 @@
%p= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.')
.settings-content
+ = render_if_exists 'projects/settings/restore', project: @project
+
.sub-section
%h4= _('Housekeeping')
%p
@@ -107,6 +109,6 @@
.save-project-loader.hide
.center
%h2
- .gl-spinner.gl-spinner-md.align-text-bottom
+ = gl_loading_icon(inline: true, size: 'md', css_class: 'gl-vertical-align-middle')
= _('Saving project.')
%p= _('Please wait a moment, this page will automatically refresh when ready.')
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index c3fbf774faa..6a54eedf6c8 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -1,5 +1,6 @@
- @content_class = "limit-container-width" unless fluid_layout
- default_branch_name = @project.default_branch_or_main
+- escaped_default_branch_name = default_branch_name.shellescape
- @skip_current_level_breadcrumb = true
= render partial: 'flash_messages', locals: { project: @project }
@@ -31,51 +32,47 @@
%p
= _('You can also upload existing files from your computer using the instructions below.')
.git-empty.js-git-empty
- %fieldset
- %h5= _('Git global setup')
- %pre.bg-light
- :preserve
- git config --global user.name "#{h git_user_name}"
- git config --global user.email "#{h git_user_email}"
+ %h5= _('Git global setup')
+ %pre.bg-light
+ :preserve
+ git config --global user.name "#{h git_user_name}"
+ git config --global user.email "#{h git_user_email}"
- %fieldset
- %h5= _('Create a new repository')
- %pre.bg-light
- :preserve
- git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
- cd #{h @project.path}
- git switch -c #{h default_branch_name}
- touch README.md
- git add README.md
- git commit -m "add README"
- - if @project.can_current_user_push_to_default_branch?
- %span><
- git push -u origin #{h default_branch_name }
+ %h5= _('Create a new repository')
+ %pre.bg-light
+ :preserve
+ git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
+ cd #{h @project.path}
+ git switch -c #{h escaped_default_branch_name}
+ touch README.md
+ git add README.md
+ git commit -m "add README"
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin #{h escaped_default_branch_name }
- %fieldset
- %h5= _('Push an existing folder')
- %pre.bg-light
- :preserve
- cd existing_folder
- git init --initial-branch=#{h default_branch_name}
- git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
- git add .
- git commit -m "Initial commit"
- - if @project.can_current_user_push_to_default_branch?
- %span><
- git push -u origin #{h default_branch_name }
+ %h5= _('Push an existing folder')
+ %pre.bg-light
+ :preserve
+ cd existing_folder
+ git init --initial-branch=#{h escaped_default_branch_name}
+ git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
+ git add .
+ git commit -m "Initial commit"
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin #{h escaped_default_branch_name }
- %fieldset
- %h5= _('Push an existing Git repository')
- %pre.bg-light
- :preserve
- cd existing_repo
- git remote rename origin old-origin
- git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
- - if @project.can_current_user_push_to_default_branch?
- %span><
- git push -u origin --all
- git push -u origin --tags
+ %h5= _('Push an existing Git repository')
+ %pre.bg-light
+ :preserve
+ cd existing_repo
+ git remote rename origin old-origin
+ git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin --all
+ git push -u origin --tags
- if @project.upload_anchor_data.present?
= render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, default_branch_name), ref: default_branch_name, method: :post
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 2b05ffe3eea..e4b8750b96c 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -1,19 +1,11 @@
- page_title _("Environments")
- add_page_specific_style 'page_bundles/environments'
-- if Feature.enabled?(:new_environments_table)
- #environments-table{ data: { endpoint: project_environments_path(@project, format: :json),
- "can-read-environment" => can?(current_user, :read_environment, @project).to_s,
- "can-create-environment" => can?(current_user, :create_environment, @project).to_s,
- "new-environment-path" => new_project_environment_path(@project),
- "help-page-path" => help_page_path("ci/environments/index.md"),
- "project-path" => @project.full_path,
- "default-branch-name" => @project.default_branch_or_main } }
-- else
- #environments-list-view{ data: { environments_data: environments_list_data,
- "can-read-environment" => can?(current_user, :read_environment, @project).to_s,
- "can-create-environment" => can?(current_user, :create_environment, @project).to_s,
- "new-environment-path" => new_project_environment_path(@project),
- "help-page-path" => help_page_path("ci/environments/index.md"),
- "project-path" => @project.full_path,
- "default-branch-name" => @project.default_branch_or_main } }
+#environments-table{ data: { endpoint: project_environments_path(@project, format: :json),
+ "can-read-environment" => can?(current_user, :read_environment, @project).to_s,
+ "can-create-environment" => can?(current_user, :create_environment, @project).to_s,
+ "new-environment-path" => new_project_environment_path(@project),
+ "help-page-path" => help_page_path("ci/environments/index.md"),
+ "project-path" => @project.full_path,
+ "project-id" => @project.id,
+ "default-branch-name" => @project.default_branch_or_main } }
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index 194b10e9ef4..af5ad06d30e 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -23,5 +23,4 @@
= _('There are no matching files')
%p.text-secondary
= _('Try using a different search term to find the file you are looking for.')
- .text-center.gl-mt-3.loading
- = loading_icon(size: 'md')
+ = gl_loading_icon(size: 'md', css_class: 'gl-mt-3 loading')
diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml
deleted file mode 100644
index 84259890a44..00000000000
--- a/app/views/projects/forks/_fork_button.html.haml
+++ /dev/null
@@ -1,20 +0,0 @@
-- avatar = namespace_icon(namespace, 100)
-- can_create_project = current_user.can?(:create_projects, namespace)
-
-.bordered-box.fork-thumbnail.text-center.gl-m-3.gl-pb-5{ class: ("disabled" unless can_create_project) }
- - if /no_((\w*)_)*avatar/.match(avatar)
- = group_icon(namespace, class: "avatar rect-avatar s100 identicon mx-auto")
- - else
- .avatar-container.s100.mx-auto.gl-mt-5
- = image_tag(avatar, class: "avatar s100")
- %h5.gl-mt-3
- = namespace.human_name
- - if forked_project = namespace.find_fork_of(@project)
- = link_to _("Go to project"), project_path(forked_project), class: "btn gl-button btn-default"
- - else
- %div{ class: ('has-tooltip' unless can_create_project),
- title: (_('You have reached your project limit') unless can_create_project) }
- = link_to _("Select"), project_forks_path(@project, namespace_key: namespace.id),
- data: { qa_selector: 'fork_namespace_button', qa_name: namespace.human_name },
- method: "POST",
- class: ["btn gl-button btn-confirm", ("disabled" unless can_create_project)]
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index 30e2e9f19d9..7933e0e07b3 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -4,7 +4,6 @@
title: _('Fork Error!'),
variant: :danger,
alert_class: 'gl-mt-5',
- is_contained: true,
dismissible: false do
.gl-alert-body
%p
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index 8848fbae9cb..7243852e1f5 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -1,30 +1,13 @@
- page_title s_("ForkProject|Fork project")
-- if Feature.enabled?(:fork_project_form, @project, default_enabled: :yaml)
- #fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'),
- endpoint: new_project_fork_path(@project, format: :json),
- new_group_path: new_group_path,
- project_full_path: project_path(@project),
- visibility_help_path: help_page_path("public_access/public_access"),
- project_id: @project.id,
- project_name: @project.name,
- project_path: @project.path,
- project_description: @project.description,
- project_visibility: @project.visibility,
- restricted_visibility_levels: Gitlab::CurrentSettings.restricted_visibility_levels.to_json } }
-- else
- .row.gl-mt-3
- .col-lg-3
- %h4.gl-mt-0
- = s_("ForkProject|Fork project")
- %p
- = s_("ForkProject|A fork is a copy of a project.")
- %br
- = s_('ForkProject|Forking a repository allows you to make changes without affecting the original project.')
- .col-lg-9
- - if @own_namespace.present?
- .fork-thumbnail-container.js-fork-content
- %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3
- = s_("ForkProject|Select a namespace to fork the project")
- = render 'fork_button', namespace: @own_namespace
- #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json) } }
+#fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'),
+ endpoint: new_project_fork_path(@project, format: :json),
+ new_group_path: new_group_path,
+ project_full_path: project_path(@project),
+ visibility_help_path: help_page_path("public_access/public_access"),
+ project_id: @project.id,
+ project_name: @project.name,
+ project_path: @project.path,
+ project_description: @project.description,
+ project_visibility: @project.visibility,
+ restricted_visibility_levels: Gitlab::CurrentSettings.restricted_visibility_levels.to_json } }
diff --git a/app/views/projects/google_cloud/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml
new file mode 100644
index 00000000000..3a6f8ca059d
--- /dev/null
+++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml
@@ -0,0 +1,8 @@
+- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
+- breadcrumb_title _('Regions')
+- page_title _('Regions')
+
+- @content_class = "limit-container-width" unless fluid_layout
+
+= form_tag project_google_cloud_gcp_regions_path(@project), method: 'post' do
+ #js-google-cloud{ data: @js_data }
diff --git a/app/views/projects/harbor/repositories/index.html.haml b/app/views/projects/harbor/repositories/index.html.haml
new file mode 100644
index 00000000000..b3f5b91596d
--- /dev/null
+++ b/app/views/projects/harbor/repositories/index.html.haml
@@ -0,0 +1,9 @@
+- page_title _("Harbor Registry")
+- @content_class = "limit-container-width" unless fluid_layout
+
+#js-harbor-registry-list-project{ data: { endpoint: project_harbor_registry_index_path(@project),
+ "no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
+ "containers_error_image" => image_path('illustrations/docker-error-state.svg'),
+ "help_page_path" => help_page_path('user/packages/container_registry/index'),
+ connection_error: (!!@connection_error).to_s,
+ invalid_path_error: (!!@invalid_path_error).to_s, } }
diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml
index 0c1efab2195..8096bc6cead 100644
--- a/app/views/projects/imports/show.html.haml
+++ b/app/views/projects/imports/show.html.haml
@@ -4,7 +4,7 @@
.save-project-loader
.center
%h2
- = loading_icon
+ = gl_loading_icon(inline: true)
= import_in_progress_title
- if !has_ci_cd_only_params? && @project.external_import?
%p.monospace git clone --bare #{@project.safe_import_url}
diff --git a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
index 662270fb8e1..26bd65fbe26 100644
--- a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
+++ b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
@@ -4,7 +4,6 @@
= render 'shared/global_alert',
variant: :warning,
- is_contained: true,
close_button_class: 'js-close',
alert_class: 'hide js-alert-moved-from-service-desk-warning gl-mt-5' do
.gl-alert-body.gl-mr-3
diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml
index d85c448b29a..34e46807fb6 100644
--- a/app/views/projects/issues/_form.html.haml
+++ b/app/views/projects/issues/_form.html.haml
@@ -1,3 +1,3 @@
= form_for [@project, @issue],
- html: { class: 'issue-form common-note-form gl-mt-3 js-quick-submit js-requires-input' } do |f|
+ html: { class: 'issue-form common-note-form gl-mt-3 js-quick-submit gl-show-field-errors' } do |f|
= render 'shared/issuable/form', f: f, issuable: @issue
diff --git a/app/views/projects/issues/_service_desk_empty_state.html.haml b/app/views/projects/issues/_service_desk_empty_state.html.haml
index 3e0b80700fe..efc319ed8df 100644
--- a/app/views/projects/issues/_service_desk_empty_state.html.haml
+++ b/app/views/projects/issues/_service_desk_empty_state.html.haml
@@ -6,7 +6,7 @@
- if Gitlab::ServiceDesk.supported?
.empty-state
.svg-content
- = render 'shared/empty_states/icons/service_desk_empty_state.svg'
+ = render partial: 'shared/empty_states/icons/service_desk_empty_state', formats: :svg
.text-content
%h4= title_text
@@ -25,7 +25,7 @@
- else
.empty-state
.svg-content
- = render 'shared/empty_states/icons/service_desk_setup.svg'
+ = render partial: 'shared/empty_states/icons/service_desk_setup', formats: :svg
.text-content
- if can_edit_project_settings
%h4= s_('ServiceDesk|Service Desk is not supported')
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 10c48177ae4..d74b6c0639c 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -14,7 +14,7 @@
project_path: @project.full_path } }
- if Feature.enabled?(:vue_issues_list, @project&.group, default_enabled: :yaml)
- .js-issues-list{ data: project_issues_list_data(@project, current_user, finder) }
+ .js-issues-list{ data: project_issues_list_data(@project, current_user) }
- if @can_bulk_update
= render 'shared/issuable/bulk_update_sidebar', type: :issues
- elsif project_issues(@project).exists?
diff --git a/app/views/projects/learn_gitlab/index.html.haml b/app/views/projects/learn_gitlab/index.html.haml
index 9924b172875..0e950c26d34 100644
--- a/app/views/projects/learn_gitlab/index.html.haml
+++ b/app/views/projects/learn_gitlab/index.html.haml
@@ -5,8 +5,4 @@
= render 'projects/invite_members_modal', project: @project
-- experiment(:confetti_post_signup, actor: current_user) do |e|
- - e.control do
- #js-learn-gitlab-app{ data: data }
- - e.candidate do
- #js-learn-gitlab-app{ data: data.merge(invite_members: 'true') }
+#js-learn-gitlab-app{ data: data }
diff --git a/app/views/projects/merge_requests/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml
index ecf5df5d3b4..14ddaf8d2b7 100644
--- a/app/views/projects/merge_requests/_commits.html.haml
+++ b/app/views/projects/merge_requests/_commits.html.haml
@@ -5,7 +5,7 @@
= custom_icon ('illustration_no_commits')
%h4
= _('There are no commits yet.')
- - if @project&.context_commits_enabled? && can_update_merge_request
+ - if can_update_merge_request
%p
= _('Push commits to the source branch or add previously merged commits to review them.')
%button.btn.gl-button.btn-confirm.add-review-item-modal-trigger{ type: "button", data: { commits_empty: 'true', context_commits_empty: 'true' } }
@@ -14,5 +14,5 @@
%ol#commits-list.list-unstyled
= render "projects/commits/commits", merge_request: @merge_request
-- if @project&.context_commits_enabled? && can_update_merge_request && @merge_request.iid
+- if can_update_merge_request && @merge_request.iid
.add-review-item-modal-wrapper{ data: { context_commits_path: context_commits_project_json_merge_request_url(@merge_request&.project, @merge_request, :json), target_branch: @merge_request.target_branch, merge_request_iid: @merge_request.iid, project_id: @merge_request.project.id } }
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index f2a271da771..d894aeaad65 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -6,12 +6,12 @@
= cache(cache_key, expires_in: 1.day) do
- if @merge_request.closed_or_merged_without_fork?
- .gl-alert.gl-alert-danger.gl-mb-5
- .gl-alert-container
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-body
- The source project of this merge request has been removed.
+ = render 'shared/global_alert',
+ alert_class: 'gl-mb-5',
+ variant: :danger,
+ dismissible: false do
+ .gl-alert-body
+ = _('The source project of this merge request has been removed.')
.detail-page-header.border-bottom-0.pt-0.pb-0
.detail-page-header-body
@@ -45,9 +45,9 @@
%li= link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
- if can_update_merge_request
- = link_to _('Edit'), edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" }
+ = link_to _('Edit'), edit_project_merge_request_path(@project, @merge_request), class: "gl-display-none gl-md-display-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" }
- if can_update_merge_request && !are_close_and_open_buttons_hidden
= render 'projects/merge_requests/close_reopen_draft_report_toggle'
- elsif !@merge_request.merged?
- = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-md-display-block gl-button btn btn-default float-right gl-ml-3', title: _('Report abuse')
+ = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-md-display-block gl-button btn btn-default gl-float-right gl-ml-3', title: _('Report abuse')
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 ea778517374..e2ac8ef5abc 100644
--- a/app/views/projects/merge_requests/creations/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -29,8 +29,7 @@
= dropdown_content
= dropdown_loading
.card-footer
- .text-center
- .js-source-loading.mt-1.gl-spinner
+ = gl_loading_icon(css_class: 'js-source-loading gl-my-3')
%ul.list-unstyled.mr_source_commit
.col-lg-6
@@ -58,8 +57,7 @@
= dropdown_content
= dropdown_loading
.card-footer
- .text-center
- .js-target-loading.mt-1.gl-spinner
+ = gl_loading_icon(css_class: 'js-target-loading gl-my-3')
%ul.list-unstyled.mr_target_commit
- if @merge_request.errors.any?
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 0036f1b4bde..253f50d5090 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -48,4 +48,4 @@
.mr-loading-status
.loading.hide
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md')
diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml
index 28fd0b83824..aa68fe031bb 100644
--- a/app/views/projects/merge_requests/invalid.html.haml
+++ b/app/views/projects/merge_requests/invalid.html.haml
@@ -9,16 +9,15 @@
= render "projects/merge_requests/mr_title"
= render "projects/merge_requests/mr_box"
- .gl-alert.gl-alert-danger
- .gl-alert-container
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content{ role: 'alert' }
- .gl-alert-body
- - if @merge_request.for_fork? && !@merge_request.source_project
- = err_fork_project_removed
- - elsif !@merge_request.source_branch_exists?
- = err_source_branch.html_safe % { branch_badge: gl_badge_tag(@merge_request.source_branch, variant: :info, size: :sm), path_badge: gl_badge_tag(@merge_request.source_project_path, variant: :info, size: :sm) }
- - elsif !@merge_request.target_branch_exists?
- = err_target_branch.html_safe % { branch_badge: gl_badge_tag(@merge_request.target_branch, variant: :info, size: :sm), path_badge: gl_badge_tag(@merge_request.source_project_path, variant: :info, size: :sm) }
- - else
- = err_internal
+ = render 'shared/global_alert',
+ variant: :danger,
+ dismissible: false do
+ .gl-alert-body
+ - if @merge_request.for_fork? && !@merge_request.source_project
+ = err_fork_project_removed
+ - elsif !@merge_request.source_branch_exists?
+ = err_source_branch.html_safe % { branch_badge: gl_badge_tag(@merge_request.source_branch, variant: :info, size: :sm), path_badge: gl_badge_tag(@merge_request.source_project_path, variant: :info, size: :sm) }
+ - elsif !@merge_request.target_branch_exists?
+ = err_target_branch.html_safe % { branch_badge: gl_badge_tag(@merge_request.target_branch, variant: :info, size: :sm), path_badge: gl_badge_tag(@merge_request.source_project_path, variant: :info, size: :sm) }
+ - else
+ = err_internal
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index a7667d03138..008f2588dbd 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -76,14 +76,12 @@
= render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do
- if @number_of_pipelines.nonzero?
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
- - params = request.query_parameters
- - if Feature.enabled?(:default_merge_ref_for_diffs, @project, default_enabled: :yaml)
- - params = params.merge(diff_head: true)
+ - params = request.query_parameters.merge(diff_head: true)
= render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: diffs_tab_pane_data(@project, @merge_request, params)
.mr-loading-status
.loading.hide
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'lg')
= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch
@@ -94,5 +92,8 @@
#js-review-bar
+- if Feature.enabled?(:mr_attention_requests, default_enabled: :yaml)
+ #js-need-attention-sidebar-onboarding
+
= render 'projects/invite_members_modal', project: @project
= render 'shared/web_ide_path'
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index dbde3346b81..225f8c7dd66 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -15,7 +15,6 @@
= render 'shared/global_alert',
variant: :info,
dismissible: false,
- is_contained: true,
alert_data: { testid: 'no-issues-alert' },
alert_class: 'gl-mt-3 gl-mb-5' do
.gl-alert-body
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index b2fa735f76f..3af95633214 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -37,8 +37,9 @@
.panel-footer
= f.submit _('Mirror repository'), class: 'gl-button btn btn-confirm js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
- else
- .gl-alert.gl-alert-info{ role: 'alert' }
- = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ = render 'shared/global_alert',
+ dismissible: false,
+ variant: :info do
.gl-alert-body
= _('Mirror settings are only available to GitLab administrators.')
diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml
index 4cabb930433..b6700c9ed1e 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -16,5 +16,4 @@
- if @commit
.network-graph.gl-bg-white.gl-overflow-scroll.gl-overflow-x-hidden{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } }
- .text-center.gl-mt-3
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md', css_class: 'gl-mt-3')
diff --git a/app/views/projects/pages/_ssl_limitations_warning.html.haml b/app/views/projects/pages/_ssl_limitations_warning.html.haml
index de74b703e95..24f51aa91e6 100644
--- a/app/views/projects/pages/_ssl_limitations_warning.html.haml
+++ b/app/views/projects/pages/_ssl_limitations_warning.html.haml
@@ -2,6 +2,6 @@
= sprite_icon("warning-solid", css_class: "gl-text-orange-600")
%strong= _("Warning:")
- pages_host = Gitlab.config.pages.host
- - docs_link_start = "<a href='#{help_page_path('user/project/pages/introduction', anchor: 'limitations')}' target='_blank' rel='noopener noreferrer'>".html_safe
+ - docs_link_start = "<a href='#{help_page_path('user/project/pages/introduction', anchor: 'subdomains-of-subdomains')}' target='_blank' rel='noopener noreferrer'>".html_safe
- link_end = '</a>'.html_safe
- = s_("GitLabPages|When using Pages under the general domain of a GitLab instance (%{pages_host}), you cannot use HTTPS with sub-subdomains. This means that if your username/groupname contains a dot it will not work. This is a limitation of the HTTP Over TLS protocol. HTTP pages will continue to work provided you don't redirect HTTP to HTTPS. %{docs_link_start}Learn more.%{link_end}").html_safe % { pages_host: pages_host, docs_link_start: docs_link_start, link_end: link_end }
+ = s_("GitLabPages|When using Pages under the general domain of a GitLab instance (%{pages_host}), you cannot use HTTPS with subdomains of subdomains. If your namespace or groupname contains a dot, it does not work. This is a limitation of the HTTP Over TLS protocol. HTTP pages work if you don't redirect HTTP to HTTPS. %{docs_link_start}Learn more.%{link_end}").html_safe % { pages_host: pages_host, docs_link_start: docs_link_start, link_end: link_end }
diff --git a/app/views/projects/pages_domains/_certificate.html.haml b/app/views/projects/pages_domains/_certificate.html.haml
index 33db7896065..861305dc93b 100644
--- a/app/views/projects/pages_domains/_certificate.html.haml
+++ b/app/views/projects/pages_domains/_certificate.html.haml
@@ -14,14 +14,13 @@
- lets_encrypt_link_start = "<a href=\"%{lets_encrypt_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { lets_encrypt_link_url: lets_encrypt_link_url }
- lets_encrypt_link_end = "</a>".html_safe
= _("Automatic certificate management using %{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end}").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: lets_encrypt_link_end }
- %button{ type: "button", id: "pages_domain_auto_ssl_enabled_button",
- class: "js-project-feature-toggle project-feature-toggle mt-2 #{"is-checked" if auto_ssl_available_and_enabled}",
- "aria-label": _("Automatic certificate management using Let's Encrypt") }
+ = render Pajamas::ToggleComponent.new(id: 'pages_domain_auto_ssl_enabled_button',
+ classes: 'js-project-feature-toggle js-enable-ssl-gl-toggle mt-2',
+ is_checked: auto_ssl_available_and_enabled,
+ label: _("Automatic certificate management using Let's Encrypt"),
+ label_position: :hidden)
= f.hidden_field :auto_ssl_enabled?, class: "js-project-feature-toggle-input"
- %span.toggle-icon
- = sprite_icon("status_success_borderless", size: 18, css_class: "gl-text-blue-500 toggle-status-checked")
- = sprite_icon("status_failed_borderless", size: 18, css_class: "gl-text-gray-400 toggle-status-unchecked")
- %p.text-secondary.mt-3
+ %p.gl-text-secondary.gl-mt-1
- docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md")
- docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url }
- docs_link_end = "</a>".html_safe
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index 66aee7dedf3..0818c3d5cff 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -15,8 +15,9 @@
= f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true
.form-group.row
.col-md-9
- = f.label :ref, _('Target Branch'), class: 'label-bold'
- = dropdown_tag(_("Select target branch"), options: { toggle_class: 'gl-button btn btn-default js-target-branch-dropdown w-100', dropdown_class: 'git-revision-dropdown w-100', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
+ = f.label :ref, Feature.enabled?(:pipeline_schedules_with_tags, default_enabled: :yaml) ? _('Target branch or tag') : _('Target branch'), class: 'label-bold'
+ %div{ data: { testid: 'schedule-target-ref' } }
+ .js-target-ref-dropdown{ data: { project_id: @project.id, default_branch: @project.default_branch } }
= f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
.form-group.row.js-ci-variable-list-section
.col-md-9
@@ -24,8 +25,8 @@
#{ s_('PipelineSchedules|Variables') }
%ul.ci-variable-list
- @schedule.variables.each do |variable|
- = render 'ci/variables/variable_row', form_field: 'schedule', variable: variable, only_key_value: true
- = render 'ci/variables/variable_row', form_field: 'schedule', only_key_value: true
+ = render 'ci/variables/variable_row', form_field: 'schedule', variable: variable
+ = render 'ci/variables/variable_row', form_field: 'schedule'
- if @schedule.variables.size > 0
%button.gl-button.btn.btn-confirm-secondary.gl-mt-3.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@schedule.variables.size == 0}" } }
- if @schedule.variables.size == 0
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index 908de68f825..edcd44563f7 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -3,9 +3,12 @@
%td
= pipeline_schedule.description
%td.branch-name-cell
- = sprite_icon('fork', size: 12)
+ - if pipeline_schedule.for_tag?
+ = sprite_icon('tag', size: 12)
+ - else
+ = sprite_icon('fork', size: 12)
- if pipeline_schedule.ref.present?
- = link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name"
+ = link_to pipeline_schedule.ref_for_display, project_ref_path(@project, pipeline_schedule.ref_for_display), class: "ref-name"
%td
- if pipeline_schedule.last_pipeline
.status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" }
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 4e93d7a04e7..54435f675a7 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -27,7 +27,7 @@
- if @pipeline.latest?
= gl_badge_tag s_('Pipelines|latest'), { variant: :success, size: :sm }, { class: 'js-pipeline-url-latest has-tooltip', title: _("Latest pipeline for the most recent commit on this branch") }
- if @pipeline.merge_train_pipeline?
- = gl_badge_tag s_('Pipelines|train'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-train has-tooltip', title: _("This is a merge train pipeline") }
+ = gl_badge_tag s_('Pipelines|merge train'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-train has-tooltip', title: s_("Pipelines|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.") }
- if @pipeline.has_yaml_errors?
= gl_badge_tag s_('Pipelines|yaml invalid'), { variant: :danger, size: :sm }, { class: 'js-pipeline-url-yaml has-tooltip', title: @pipeline.yaml_errors }
- if @pipeline.failure_reason?
@@ -38,7 +38,7 @@
- popover_content_text = _('Learn more about Auto DevOps')
= gl_badge_tag s_('Pipelines|Auto DevOps'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-autodevops', href: "#", tabindex: "0", role: "button", data: { container: 'body', toggle: 'popover', placement: 'top', html: 'true', triggers: 'focus', title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>", content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>" } }
- if @pipeline.detached_merge_request_pipeline?
- = gl_badge_tag s_('Pipelines|detached'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-mergerequest has-tooltip', title: _('Merge request pipelines are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for merge request pipelines.') }
+ = gl_badge_tag s_('Pipelines|merge request'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-mergerequest has-tooltip', title: s_("Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.") }
- if @pipeline.stuck?
= gl_badge_tag s_('Pipelines|stuck'), { variant: :warning, size: :sm }, { class: 'js-pipeline-url-stuck has-tooltip' }
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index e844a3d4779..88e6b98b115 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -29,20 +29,7 @@
#js-tab-builds.tab-pane
- if stages.present?
- - if Feature.enabled?(:jobs_tab_vue, @project, default_enabled: :yaml)
- #js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } }
- - else
- .table-holder.pipeline-holder
- %table.table.ci-table.pipeline
- %thead
- %tr
- %th= _('Status')
- %th= _('Name')
- %th= _('Job ID')
- %th
- %th= _('Coverage')
- %th
- = render partial: "projects/stage/stage", collection: stages, as: :stage
+ #js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } }
- if @pipeline.failed_builds.present?
#js-tab-failures.build-failures.tab-pane.build-page
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index 547e2c8a7f4..5a655e7e83d 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -5,4 +5,5 @@
should_render_quality_summary: should_render_quality_summary.to_s,
failed_pipelines_link: project_pipelines_path(@project, page: '1', scope: 'all', status: 'failed'),
coverage_chart_path: charts_project_graph_path(@project, @project.default_branch),
+ test_runs_empty_state_image_path: image_path('illustrations/pipeline.svg'),
default_branch: @project.default_branch } }
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index ae76d4905e0..f4b242ffc40 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -1,28 +1,10 @@
- page_title _('Pipelines')
- add_page_specific_style 'page_bundles/pipelines'
- add_page_specific_style 'page_bundles/ci_status'
-- artifacts_endpoint_placeholder = ':pipeline_artifacts_id'
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
-- list_url = project_pipelines_path(@project, format: :json, code_quality_walkthrough: params[:code_quality_walkthrough])
+- list_url = project_pipelines_path(@project, format: :json)
- add_page_startup_api_call list_url
-#pipelines-list-vue{ data: { endpoint: list_url,
- project_id: @project.id,
- params: params.to_json,
- "artifacts-endpoint" => downloadable_artifacts_project_pipeline_path(@project, artifacts_endpoint_placeholder, format: :json),
- "artifacts-endpoint-placeholder" => artifacts_endpoint_placeholder,
- "pipeline-schedule-url" => pipeline_schedules_path(@project),
- "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
- "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
- "no-pipelines-svg-path" => image_path('illustrations/pipelines_pending.svg'),
- "can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s,
- "new-pipeline-path" => can?(current_user, :create_pipeline, @project) && new_project_pipeline_path(@project),
- "ci-lint-path" => can?(current_user, :create_pipeline, @project) && project_ci_lint_path(@project),
- "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project),
- "has-gitlab-ci" => has_gitlab_ci?(@project).to_s,
- "pipeline-editor-path" => can?(current_user, :create_pipeline, @project) && project_ci_pipeline_editor_path(@project),
- "suggested-ci-templates" => suggested_ci_templates.to_json,
- "code-quality-page-path" => @project.present(current_user: current_user).add_code_quality_ci_yml_path,
- "ci-runner-settings-path" => project_settings_ci_cd_path(@project, ci_runner_templates: true, anchor: 'js-runners-settings') } }
+#pipelines-list-vue{ data: pipelines_list_data(@project, list_url) }
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 70815dbe7a7..ba498352278 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -20,7 +20,7 @@
.bs-callout.bs-callout-danger
%h4= _('Found errors in your %{gitlab_ci_yml}:') % { gitlab_ci_yml: '.gitlab-ci.yml' }
%ul
- - @pipeline.yaml_errors.split(",").each do |error|
+ - @pipeline.yaml_errors.split("\n").each do |error|
%li= error
- lint_link_url = project_ci_pipeline_editor_path(@project, tab: "LINT_TAB")
- lint_link_start = '<a href="%{url}" class="gl-text-blue-500!">'.html_safe % { url: lint_link_url }
diff --git a/app/views/projects/project_members/import.html.haml b/app/views/projects/project_members/import.html.haml
deleted file mode 100644
index 2f953db0d65..00000000000
--- a/app/views/projects/project_members/import.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-- page_title _("Import members")
-
-%h3.page-title
- = _("Import members from another project")
-%p.light
- = _("Only project members will be imported. Group members will be skipped.")
-%hr
-= form_tag apply_import_project_project_members_path(@project), method: 'post' do
- .form-group.row
- = label_tag :source_project_id, _("Project"), class: 'col-form-label col-sm-2'
- .col-sm-10= select_tag(:source_project_id, options_from_collection_for_select(@projects, :id, :name_with_namespace), prompt: "Select project", class: "select2 lg", required: true)
-
- .form-actions
- = button_tag _('Import project members'), class: "btn gl-button btn-success"
- = link_to _("Cancel"), project_project_members_path(@project), class: "btn gl-button btn-cancel"
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 220e44679cd..f97b9a2b02f 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -23,7 +23,7 @@
.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') } }
= render 'projects/invite_groups_modal', project: @project
- if can_admin_project_member?(@project)
- .js-invite-members-trigger{ data: { variant: 'success',
+ .js-invite-members-trigger{ data: { variant: 'confirm',
classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',
trigger_source: 'project-members-page',
display_text: _('Invite members') } }
@@ -39,51 +39,9 @@
%p
= html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe }
- - if Feature.disabled?(:invite_members_group_modal, @project.group, default_enabled: :yaml) && can?(current_user, :admin_project_member, @project) && project_can_be_shared?
- - if !membership_locked? && @project.allowed_to_share_with_group?
- %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
- %li.nav-tab{ role: 'presentation' }
- %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member")
- %li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) }
- %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group")
-
- .tab-content.gitlab-tab-content
- .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
- = render 'shared/members/invite_member',
- submit_url: project_project_members_path(@project),
- access_levels: ProjectMember.access_level_roles,
- default_access_level: @project_member.access_level,
- can_import_members?: can_admin_project_member?(@project),
- import_path: import_project_project_members_path(@project)
- .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) }
- = render 'shared/members/invite_group',
- submit_url: project_group_links_path(@project),
- access_levels: ProjectGroupLink.access_options,
- default_access_level: ProjectGroupLink.default_access,
- group_link_field: 'link_group_id',
- group_access_field: 'link_group_access',
- groups_select_tag_data: { min_access_level: Gitlab::Access::GUEST, skip_groups: @skip_groups }
- - elsif !membership_locked?
- .invite-member
- = render 'shared/members/invite_member',
- submit_url: project_project_members_path(@project),
- access_levels: ProjectMember.access_level_roles,
- default_access_level: @project_member.access_level,
- can_import_members?: can_admin_project_member?(@project),
- import_path: import_project_project_members_path(@project)
- - elsif @project.allowed_to_share_with_group?
- .invite-group
- = render 'shared/members/invite_group',
- access_levels: ProjectGroupLink.access_options,
- default_access_level: ProjectGroupLink.default_access,
- submit_url: project_group_links_path(@project),
- group_link_field: 'link_group_id',
- group_access_field: 'link_group_access',
- groups_select_tag_data: { min_access_level: Gitlab::Access::GUEST, skip_groups: @skip_groups }
.js-project-members-list-app{ data: { members_data: project_members_app_data_json(@project,
members: @project_members,
group_links: @group_links,
invited: @invited_members,
access_requests: @requesters) } }
- .loading
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(css_class: 'gl-my-5', size: 'md')
diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
index 57fc9a16c0a..e5810930be2 100644
--- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
@@ -24,8 +24,9 @@
.form-group.row
= f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-md-2 gl-text-left text-md-right'
.col-md-10
- = render "shared/buttons/project_feature_toggle", class_list: "js-force-push-toggle project-feature-toggle"
- .form-text.gl-text-gray-600.gl-mt-0
+ = render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle',
+ label: s_("ProtectedBranch|Allowed to force push"),
+ label_position: :hidden) do
- force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push')
- force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url }
= (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 3efe1fd2e82..aab5e9fca98 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -15,6 +15,7 @@
"expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
+ "container_registry_importing_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'tags-temporarily-cannot-be-marked-for-deletion'),
"project_path": @project.full_path,
"gid_prefix": container_repository_gid_prefix,
"is_admin": current_user&.admin.to_s,
diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml
index c25fd7a7587..8134ee8f417 100644
--- a/app/views/projects/runners/_group_runners.html.haml
+++ b/app/views/projects/runners/_group_runners.html.haml
@@ -28,7 +28,11 @@
= _('This group does not have any group runners yet.')
- if can?(current_user, :admin_group_runners, @project.group)
- - group_link = link_to _("group's CI/CD settings."), group_settings_ci_cd_path(@project.group)
+ - if Feature.enabled?(:runner_list_group_view_vue_ui, @group, default_enabled: :yaml)
+ - register_runners_path = group_runners_path(@project.group)
+ - else
+ - register_runners_path = group_settings_ci_cd_path(@project.group)
+ - group_link = link_to _("group's CI/CD settings."), register_runners_path
= _('Group owners can register group runners in the %{link}').html_safe % { link: group_link }
- else
= _('Ask your group owner to set up a group runner.')
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index 28e5618f8b0..5eaf6c9d22b 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -16,10 +16,10 @@
= link_to edit_project_runner_path(@project, runner), class: 'btn gl-button btn-icon', title: _('Edit'), aria: { label: _('Edit') }, data: { testid: 'edit-runner-link', toggle: 'tooltip', placement: 'top', container: 'body' } do
= sprite_icon('pencil')
- if runner.active?
- = link_to pause_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: _('Pause'), aria: { label: _('Pause') }, data: { toggle: 'tooltip', placement: 'top', container: 'body', confirm: _("Are you sure?") } do
+ = link_to pause_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: s_('Runners|Pause from accepting jobs'), aria: { label: _('Pause') }, data: { toggle: 'tooltip', container: 'body', confirm: _("Are you sure?") } do
= sprite_icon('pause')
- else
- = link_to resume_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: _('Resume'), aria: { label: _('Resume') }, data: { toggle: 'tooltip', placement: 'top', container: 'body' } do
+ = link_to resume_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: s_('Runners|Resume accepting jobs'), aria: { label: _('Resume') }, data: { toggle: 'tooltip', container: 'body' } do
= sprite_icon('play')
- if runner.belongs_to_one_project?
= link_to _('Remove runner'), project_runner_path(@project, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn gl-button btn-danger'
diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml
index 1357846876e..3634bacb6ec 100644
--- a/app/views/projects/runners/_specific_runners.html.haml
+++ b/app/views/projects/runners/_specific_runners.html.haml
@@ -2,7 +2,7 @@
= _('Specific runners')
.bs-callout.help-callout
- - if valid_runner_registrars.include?('project')
+ - if can?(current_user, :register_project_runners, @project)
= _('These runners are specific to this project.')
- if params[:ci_runner_templates]
%hr
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index 65b93dc930a..7a47b504b7c 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -1,6 +1,17 @@
- if lookup_context.template_exists?('top', "projects/services/#{integration.to_param}", true)
= render "projects/services/#{integration.to_param}/top", integration: integration
+- if integration.activate_disabled_reason.present? && integration.activate_disabled_reason[:trackers].any?
+ -# When using integration.activate_disabled_reason[:trackers], it's potentially insecure to use the raw records
+ -# when passed directly to the frontend. Only use specific fields that are needed for render.
+ -# For example, we can get the link to each tracker with scoped_edit_integration_path(tracker, tracker.project)
+ = render 'shared/global_alert',
+ title: s_('ExternalIssueIntegration|Another issue tracker is already in use'),
+ variant: :warning,
+ dismissible: false do
+ .gl-alert-body
+ = s_('ExternalIssueIntegration|Only one issue tracker integration can be active at a time. Please disable the active tracker first and try again.')
+
%h3.page-title
= integration.title
- if integration.operating?
diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml
index 4586ee844c0..896249c6163 100644
--- a/app/views/projects/services/prometheus/_custom_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml
@@ -18,7 +18,7 @@
.flash-text
.loading-metrics.js-loading-custom-metrics
%p.m-3
- = loading_icon(css_class: 'metrics-load-spinner')
+ = gl_loading_icon(inline: true, css_class: 'metrics-load-spinner')
= s_('PrometheusService|Finding custom metrics...')
.empty-metrics.hidden.js-empty-custom-metrics
%p.text-tertiary.m-3.js-no-active-integration-text.hidden
diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml
index 0d41584652f..8794f3e24da 100644
--- a/app/views/projects/services/prometheus/_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_metrics.html.haml
@@ -16,7 +16,7 @@
.card-body
.loading-metrics.js-loading-metrics
%p.m-3
- = loading_icon(css_class: 'metrics-load-spinner')
+ = gl_loading_icon(inline: true, css_class: 'metrics-load-spinner')
= s_('PrometheusService|Finding and configuring metrics...')
.empty-metrics.hidden.js-empty-metrics
%p.text-tertiary.m-3
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
index 960b1d67610..3a62c6f41cc 100644
--- a/app/views/projects/settings/_general.html.haml
+++ b/app/views/projects/settings/_general.html.haml
@@ -23,7 +23,7 @@
.row
.form-group.col-md-9
= f.label :description, _('Project description (optional)'), class: 'label-bold'
- = f.text_area :description, class: 'form-control gl-form-input', rows: 3, maxlength: 250
+ = f.text_area :description, class: 'form-control gl-form-input', rows: 3
.row= render_if_exists 'projects/classification_policy_settings', f: f
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index c70e153ae41..66a1cbb4649 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -94,7 +94,7 @@
.input-group-text /
%p.form-text.text-muted
= html_escape(_('The regular expression used to find test coverage output in the job log. For example, use %{regex} for Simplecov (Ruby). Leave blank to disable.')) % { regex: '<code>\(\d+.\d+%\)</code>'.html_safe }
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'add-test-coverage-results-to-a-merge-request-deprecated'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'add-test-coverage-results-using-project-settings-deprecated'), target: '_blank', rel: 'noopener noreferrer'
= f.submit _('Save changes'), class: "btn gl-button btn-confirm", data: { qa_selector: 'save_general_pipelines_changes_button' }
diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml
index 07910899aa0..658b2f2e65c 100644
--- a/app/views/projects/settings/packages_and_registries/show.html.haml
+++ b/app/views/projects/settings/packages_and_registries/show.html.haml
@@ -9,7 +9,7 @@
older_than_options: older_than_options.to_json,
is_admin: current_user&.admin.to_s,
admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'),
- enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s,
+ enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s,
help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'),
show_cleanup_policy_on_alert: show_cleanup_policy_on_alert(@project).to_s,
tags_regex_help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'regex-pattern-examples') } }
diff --git a/app/views/projects/stage/_stage.html.haml b/app/views/projects/stage/_stage.html.haml
deleted file mode 100644
index 387c8fb3234..00000000000
--- a/app/views/projects/stage/_stage.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-- stage = stage.present(current_user: current_user)
-
-%tr
- %th{ colspan: 10 }
- %strong
- %a{ name: stage.name }
- %span{ class: "ci-status-link ci-status-icon-#{stage.status}" }
- = ci_icon_for_status(stage.status)
- &nbsp;
- = stage.name.titleize
-= render stage.latest_ordered_statuses, stage: false, ref: false, pipeline_link: false, allow_retry: true
-= render stage.retried_ordered_statuses, stage: false, ref: false, pipeline_link: false, retried: true
-%tr
- %td{ colspan: 10 }
- &nbsp;
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 9d082436aa7..ce036606a1c 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -33,5 +33,5 @@
= link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "gl-button btn btn-default btn-icon" do
= sprite_icon('pencil')
- if can?(current_user, :manage_trigger, trigger)
- = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button' }, method: :delete, title: "Revoke", class: "gl-button btn btn-default btn-icon btn-trigger-revoke gl-ml-3" do
+ = link_to project_trigger_path(@project, trigger), aria: { label: _('Revoke') }, data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button', confirm_btn_variant: "danger" }, method: :delete, title: "Revoke", class: "gl-button btn btn-default btn-icon btn-trigger-revoke gl-ml-3" do
= sprite_icon('remove')
diff --git a/app/views/sandbox/mermaid.html.erb b/app/views/sandbox/mermaid.html.erb
index 2d2391c8866..48c7baeaeed 100644
--- a/app/views/sandbox/mermaid.html.erb
+++ b/app/views/sandbox/mermaid.html.erb
@@ -2,6 +2,9 @@
<html>
<head>
<%= webpack_bundle_tag("sandboxed_mermaid") %>
+ <% if params[:darkMode] == 'true' %>
+ <meta name="color-scheme" content="dark light">
+ <% end %>
</head>
<body>
<div id="app"></div>
diff --git a/app/views/search/results/_blob_highlight.html.haml b/app/views/search/results/_blob_highlight.html.haml
index de1fa9a7fd5..729eda331b5 100644
--- a/app/views/search/results/_blob_highlight.html.haml
+++ b/app/views/search/results/_blob_highlight.html.haml
@@ -10,7 +10,13 @@
.line-numbers
.gl-display-flex
%span.diff-line-num.gl-pl-3
- %a.has-tooltip{ href: "#{blame_link}#L#{i}", id: "blame-L#{i}", 'data-line-number' => i, title: _('View blame') }
+ %a.has-tooltip{ href: "#{blame_link}#L#{i}",
+ id: "blame-L#{i}",
+ data: { "line_number" => i,
+ "track_action" => 'click_link',
+ "track_label" => 'git_blame',
+ "track_property" => 'search_result' },
+ title: _('View blame') }
= sprite_icon('git')
%span.diff-line-num.flex-grow-1.gl-pr-3
%a{ href: "#{blob_link}#L#{i}", id: "blob-L#{i}", 'data-line-number' => i, class: 'gl-display-flex! gl-align-items-center gl-justify-content-end' }
diff --git a/app/views/shared/_default_branch_protection.html.haml b/app/views/shared/_default_branch_protection.html.haml
index 7a6152f6d96..1a660f3f896 100644
--- a/app/views/shared/_default_branch_protection.html.haml
+++ b/app/views/shared/_default_branch_protection.html.haml
@@ -1,4 +1,4 @@
-%fieldset.form-group
- %legend.h5.gl-border-none.gl-mt-0.gl-mb-3= _('Default branch protection')
+.form-group
+ %legend.h5.gl-border-none.gl-mt-0.gl-mb-3= _('Initial default branch protection')
- Gitlab::Access.protection_options.each do |option|
= f.gitlab_ui_radio_component :default_branch_protection, option[:value], option[:label], help_text: option[:help_text]
diff --git a/app/views/shared/_gl_toggle.html.haml b/app/views/shared/_gl_toggle.html.haml
deleted file mode 100644
index afaa6b6df92..00000000000
--- a/app/views/shared/_gl_toggle.html.haml
+++ /dev/null
@@ -1,28 +0,0 @@
--# This partial renders a GlToggle root element.
--# To actually initialize the component, make sure to call the initToggle helper from ~/toggles.
-
-- classes = local_assigns.fetch(:classes)
-- name = local_assigns.fetch(:name, nil)
-- is_checked = local_assigns.fetch(:is_checked, false).to_s
-- disabled = local_assigns.fetch(:disabled, false).to_s
-- is_loading = local_assigns.fetch(:is_loading, false).to_s
-- label = local_assigns.fetch(:label, nil)
-- help = local_assigns.fetch(:help, nil)
-- label_position = local_assigns.fetch(:label_position, nil)
-- data = local_assigns.fetch(:data, {})
-
-%span{ class: classes,
- data: { name: name,
- is_checked: is_checked,
- disabled: disabled,
- is_loading: is_loading,
- label: label,
- help: help,
- label_position: label_position,
- **data } }
-
--# Leverage this block to render a rich help text. To render a plain text help text,
--# prefer the `help` parameter.
-- if yield.present?
- .gl-text-secondary.gl-mt-1
- = yield
diff --git a/app/views/shared/_global_alert.html.haml b/app/views/shared/_global_alert.html.haml
index 1eaf21fc568..cb7ad32e474 100644
--- a/app/views/shared/_global_alert.html.haml
+++ b/app/views/shared/_global_alert.html.haml
@@ -8,16 +8,14 @@
- close_button_class = local_assigns.fetch(:close_button_class, nil)
- close_button_data = local_assigns.fetch(:close_button_data, nil)
- icon = icons[variant]
-- alert_container_class = [container_class, @content_class] unless fluid_layout || local_assigns.fetch(:is_contained, false)
%div{ role: 'alert', class: ['gl-alert', "gl-alert-#{variant}", alert_class], data: alert_data }
- .gl-alert-container{ class: alert_container_class }
- = sprite_icon(icon, size: 16, css_class: "gl-alert-icon#{' gl-alert-icon-no-title' if title.nil?}")
- - if dismissible
- %button.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon.js-close{ type: 'button', aria: { label: _('Dismiss') }, class: close_button_class, data: close_button_data }
- = sprite_icon('close', size: 16)
- .gl-alert-content{ role: 'alert' }
- - if title
- %h4.gl-alert-title
- = title
- = yield
+ = sprite_icon(icon, css_class: "gl-alert-icon#{' gl-alert-icon-no-title' if title.nil?}")
+ - if dismissible
+ %button.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon.js-close{ type: 'button', aria: { label: _('Dismiss') }, class: close_button_class, data: close_button_data }
+ = sprite_icon('close')
+ .gl-alert-content{ role: 'alert' }
+ - if title
+ %h4.gl-alert-title
+ = title
+ = yield
diff --git a/app/views/shared/_logo_ukraine.svg b/app/views/shared/_logo_ukraine.svg
new file mode 100644
index 00000000000..e2c2bb3855d
--- /dev/null
+++ b/app/views/shared/_logo_ukraine.svg
@@ -0,0 +1,5 @@
+<svg width="24" height="24" class="tanuki-logo" viewBox="0 0 24 24">
+ <path d="M4.89929534,0.3165 L7.56629534,8.5025 L16.3922953,8.5025 L19.0592953,0.3165 C19.1962953,-0.1055 19.8432953,-0.1055 19.9792953,0.3165 L23.9122953,12.6095 C23.9722953,12.7935 23.9722953,12.9895 23.9192953,13.1695 L0.0392953418,13.1695 C-0.0143874393,12.9863283 -0.0119492421,12.7912726 0.0462953418,12.6095 L3.97929534,0.3165 C4.11529534,-0.1055 4.76229534,-0.1055 4.89929534,0.3165 Z" id="Path" fill="#005BBB"></path>
+ <path d="M7.20329534,9.0025 L16.7552953,9.0025 L16.8682953,8.6575 L19.5182953,0.5185 L23.4362953,12.7615 C23.4961172,12.9376949 23.435535,13.1323657 23.2862953,13.2435 L23.2852953,13.2455 L11.9852953,21.4655 L11.9792953,21.4715 L0.673295342,13.2455 C0.522422013,13.1321007 0.462258936,12.9374792 0.522295342,12.7615 L4.43929534,0.5185 L7.09029534,8.6585 L7.20329534,9.0025 Z" id="Shape" stroke="#FFFFFF" opacity="0.32" stroke-linejoin="round"></path>
+ <path d="M0.0012953418,12.8575 C-0.0152229638,13.1685309 0.127095079,13.4667211 0.379295342,13.6495 L11.9792953,22.0895 L11.9862953,22.0845 L11.9922953,22.0895 L11.9872953,22.0835 L23.5792953,13.6495 C23.8319507,13.466647 23.9743476,13.1679148 23.9572953,12.8565 L0.0012953418,12.8565 L0.0012953418,12.8575 Z" id="Path" fill="#FFD500"></path>
+</svg> \ No newline at end of file
diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml
index 08003346d09..74a397d7a03 100644
--- a/app/views/shared/_new_project_item_select.html.haml
+++ b/app/views/shared/_new_project_item_select.html.haml
@@ -1,7 +1,7 @@
- if any_projects?(@projects)
.project-item-select-holder.btn-group.gl-ml-auto.gl-mr-auto.gl-relative.gl-overflow-hidden{ class: 'gl-display-flex!' }
%a.btn.gl-button.btn-confirm.js-new-project-item-link.block-truncated.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] }, class: "gl-m-0!" }
- = loading_icon(color: 'light')
+ = gl_loading_icon(inline: true, color: 'light')
= project_select_tag :project_path, class: "project-item-select gl-absolute! gl-visibility-hidden", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled]
%button.btn.dropdown-toggle.btn-confirm.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button.qa-new-project-item-select-button.gl-p-0.gl-w-100{ class: "gl-m-0!", 'aria-label': _('Toggle project select') }
= sprite_icon('chevron-down')
diff --git a/app/views/shared/_service_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml
index 821d92e9d7e..9cdff35ead2 100644
--- a/app/views/shared/_service_ping_consent.html.haml
+++ b/app/views/shared/_service_ping_consent.html.haml
@@ -1,7 +1,6 @@
- if session[:ask_for_usage_stats_consent]
= render 'shared/global_alert',
variant: :info,
- is_contained: true,
alert_class: 'service-ping-consent-message' do
.gl-alert-body
- docs_link = link_to _('collect usage information'), help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link'
diff --git a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
index e7239661313..f21acd26ada 100644
--- a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
+++ b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
@@ -1,6 +1,6 @@
= render 'shared/global_alert',
variant: :warning,
- alert_class: 'js-recovery-settings-callout',
+ alert_class: 'js-recovery-settings-callout gl-mt-5',
alert_data: { feature_id: Users::CalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK, dismiss_endpoint: callouts_path, defer_links: 'true' },
close_button_data: { testid: 'close-account-recovery-regular-check-callout' } do
.gl-alert-body
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index a52b7236137..0b68cfe65e5 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -19,18 +19,14 @@
.row
= f.label :name, _('Token name'), class: 'label-bold col-md-12'
.col-md-6
+ - resource_type = resource.is_a?(Group) ? "group" : "project"
= f.text_field :name, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text'
- %span.form-text.text-muted.col-md-12#access_token_help_text= _('For example, the application using the token or the purpose of the token.')
+ %span.form-text.text-muted.col-md-12#access_token_help_text= _("For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type }
.row
- .form-group.col-md-6
- = f.label :expires_at, _('Expiration date'), class: 'label-bold'
- .input-icon-wrapper
-
- = render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime'
-
- .js-access-tokens-expires-at
- = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' }
+ .col
+ .js-access-tokens-expires-at{ data: expires_at_field_data }
+ = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' }
- if resource
.row
diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml
index e02c24b93f1..60641006e96 100644
--- a/app/views/shared/blob/_markdown_buttons.html.haml
+++ b/app/views/shared/blob/_markdown_buttons.html.haml
@@ -9,6 +9,10 @@
data: { "md-tag" => "_", "md-shortcuts": '["mod+i"]' },
title: sprintf(s_("MarkdownEditor|Add italic text (%{modifier_key}I)") % { modifier_key: modifier_key }) })
+ = markdown_toolbar_button({ icon: "strikethrough",
+ data: { "md-tag" => "~~", "md-shortcuts": '["mod+shift+x"]' },
+ title: sprintf(s_("MarkdownEditor|Add strikethrough text (%{modifier_key}⇧X)") % { modifier_key: modifier_key }) })
+
= markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") })
= markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") })
diff --git a/app/views/shared/buttons/_project_feature_toggle.html.haml b/app/views/shared/buttons/_project_feature_toggle.html.haml
deleted file mode 100644
index 321fbee1b35..00000000000
--- a/app/views/shared/buttons/_project_feature_toggle.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-- class_list ||= "js-project-feature-toggle project-feature-toggle"
-- data ||= nil
-- disabled ||= false
-- is_checked ||= false
-- label ||= nil
-
-%button{ type: 'button',
- class: "#{class_list} #{'is-disabled' if disabled} #{'is-checked' if is_checked}",
- "aria-label": label,
- disabled: disabled,
- data: data }
- - if yield.present?
- = yield
- %span.toggle-icon
- = sprite_icon('status_success_borderless', size: 18, css_class: 'gl-text-blue-500 toggle-status-checked')
- = sprite_icon('status_failed_borderless', size: 18, css_class: 'gl-text-gray-400 toggle-status-unchecked')
diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml
index db9c646b694..a7bf3bfb81e 100644
--- a/app/views/shared/deploy_tokens/_table.html.haml
+++ b/app/views/shared/deploy_tokens/_table.html.haml
@@ -25,7 +25,7 @@
%span.token-never-expires-label= _('Never')
%td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
%td
- .js-deploy-token-revoke-button{ data: { button_class: 'float-right', token: token.to_json, revoke_path: revoke_deploy_token_path(group_or_project, token) } }
+ .js-deploy-token-revoke-button{ data: deploy_token_revoke_button_data(token: token, group_or_project: group_or_project) }
- else
.settings-message.text-center
diff --git a/app/views/shared/doorkeeper/applications/_delete_form.html.haml b/app/views/shared/doorkeeper/applications/_delete_form.html.haml
index caa553bc2ef..7cce0652f6f 100644
--- a/app/views/shared/doorkeeper/applications/_delete_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_delete_form.html.haml
@@ -2,9 +2,9 @@
= form_tag path do
%input{ :name => "_method", :type => "hidden", :value => "delete" }
- if defined? small
- = button_tag type: "submit", class: "gl-button btn btn-danger btn-icon", data: { confirm: _("Are you sure?") } do
+ = button_tag type: "submit", class: "gl-button btn btn-danger btn-icon", data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } do
%span.sr-only
= _('Destroy')
= sprite_icon('remove')
- else
- = submit_tag _('Destroy'), data: { confirm: _("Are you sure?") }, class: submit_btn_css
+ = submit_tag _('Destroy'), data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Destroy') }, class: submit_btn_css
diff --git a/app/views/shared/errors/_gitaly_unavailable.html.haml b/app/views/shared/errors/_gitaly_unavailable.html.haml
index 96a68cbcdc6..366d4585435 100644
--- a/app/views/shared/errors/_gitaly_unavailable.html.haml
+++ b/app/views/shared/errors/_gitaly_unavailable.html.haml
@@ -1,8 +1,7 @@
-.gl-alert.gl-alert-danger.gl-mb-5.gl-mt-5
- .gl-alert-container
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-title
- = reason
- .gl-alert-body
- = s_('The git server, Gitaly, is not available at this time. Please contact your administrator.')
+= render 'shared/global_alert',
+ alert_class: 'gl-my-5',
+ variant: :danger,
+ dismissible: false,
+ title: reason do
+ .gl-alert-body
+ = s_('The git server, Gitaly, is not available at this time. Please contact your administrator.')
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index ae896b7348d..3f6e7a6fb32 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -9,7 +9,6 @@
= render 'shared/global_alert',
variant: :danger,
dismissible: false,
- is_contained: true,
alert_class: 'gl-mb-5' do
.gl-alert-body
Someone edited the #{issuable.class.model_name.human.downcase} the same time you did.
@@ -20,7 +19,9 @@
= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form
.form-group.row
- = form.label :title, class: 'col-form-label col-sm-2'
+ = form.label :title, class: 'col-form-label col-sm-2' do
+ = _('Title')
+ %i{ aria: { hidden: true } }= '*'
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
#js-suggestions{ data: { project_path: @project.full_path } }
diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml
index 84cdf129cb2..6a58acf8c05 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -6,10 +6,7 @@
.dropdown-page-two.dropdown-new-label
= dropdown_title(create_label_title(subject), options: { back: true, close: show_close })
= dropdown_content do
- .js-label-error.gl-alert.gl-alert-danger.gl-mb-3
- .gl-alert-container
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
+ = render 'shared/global_alert', variant: :danger, alert_class: 'js-label-error gl-mb-3', dismissible: false
%input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
.suggest-colors.suggest-colors-dropdown
= render_suggested_colors
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index b02c6b65359..37a79a50fb1 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -5,10 +5,6 @@
- placeholder = local_assigns[:placeholder] || _('Search or filter results...')
- block_css_class = type != :productivity_analytics ? 'row-content-block second-block' : ''
- is_epic_board = board&.to_type == "EpicBoard"
-- if @group.present?
- - ff_resource = @group
-- else
- - ff_resource = board&.resource_parent&.group
- if is_epic_board
- user_can_admin_list = can?(current_user, :admin_epic_board_list, board.resource_parent)
@@ -31,7 +27,7 @@
= check_box_tag checkbox_id, nil, false, class: "check-all-issues left"
- if is_epic_board
#js-board-filtered-search{ data: { full_path: @group&.full_path } }
- - elsif Feature.enabled?(:issue_boards_filtered_search, ff_resource, default_enabled: :yaml) && board
+ - elsif board
#js-issue-board-filtered-search
- else
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 7787e5dd660..37d31515307 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -27,7 +27,7 @@
- if issuable_sidebar[:supports_escalation]
.block.escalation-status{ data: { testid: 'escalation_status_container' } }
- #js-escalation-status{ data: { can_edit: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
+ #js-escalation-status{ data: { can_update: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
= render_if_exists 'shared/issuable/sidebar_escalation_policy', issuable_sidebar: issuable_sidebar
- if @project.group.present?
@@ -41,7 +41,7 @@
.block{ class: 'gl-pt-0! gl-collapse-empty', data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }<
= render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- - if @show_crm_contacts
+ - if issuable_sidebar[:show_crm_contacts]
.block.contact
#js-issue-crm-contacts{ data: { issue_id: issuable_sidebar[:id] } }
@@ -50,7 +50,7 @@
// Fallback while content is loading
.title.hide-collapsed
= _('Time tracking')
- = loading_icon(css_class: 'gl-vertical-align-text-bottom')
+ = gl_loading_icon(inline: true)
- if issuable_sidebar.has_key?(:due_date)
#js-due-date-entry-point
@@ -109,8 +109,8 @@
= dropdown_loading
= dropdown_footer add_content_class: true do
%button.gl-button.btn.btn-confirm.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true }
+ = gl_loading_icon(inline: true, css_class: 'sidebar-move-issue-confirmation-loading-icon gl-mr-2')
= _('Move')
- = loading_icon(css_class: 'gl-vertical-align-text-bottom sidebar-move-issue-confirmation-loading-icon')
-# haml-lint:disable InlineJavaScript
%script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 9a0b25f4015..2fd4c598580 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -7,7 +7,7 @@
directly_invite_members: can_admin_project_member?(@project) } }
.title.hide-collapsed
= _('Assignee')
- = loading_icon(css_class: 'gl-vertical-align-text-bottom')
+ = gl_loading_icon(inline: true)
.js-sidebar-assignee-data.selectbox.hide-collapsed
- if assignees.none?
diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml
index bc76d292dd6..ce252e74570 100644
--- a/app/views/shared/issuable/_sidebar_reviewers.html.haml
+++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml
@@ -3,7 +3,7 @@
#js-vue-sidebar-reviewers{ data: { field: issuable_type, signed_in: signed_in } }
.title.hide-collapsed
= _('Reviewer')
- = loading_icon(css_class: 'gl-vertical-align-text-bottom')
+ = gl_loading_icon(inline: true)
.selectbox.hide-collapsed
- if reviewers.none?
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 9e42c528a11..34720576526 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -4,6 +4,16 @@
- has_due_date = issuable.has_attribute?(:due_date)
- form = local_assigns.fetch(:form)
+- if @add_related_issue
+ .form-group.row
+ .offset-sm-2.col-sm-10
+ .form-check
+ = check_box_tag :add_related_issue, @add_related_issue.iid, true, class: 'form-check-input'
+ = label_tag :add_related_issue, class: 'form-check-label' do
+ - add_related_issue_link = link_to "\##{@add_related_issue.iid}", issue_path(@add_related_issue), class: ['has-tooltip'], title: @add_related_issue.title
+ #{_('Relate to %{issuable_type} %{add_related_issue_link}').html_safe % { issuable_type: @add_related_issue.issue_type, add_related_issue_link: add_related_issue_link }}
+ %p.text-muted= _('Adds this %{issuable_type} as related to the %{issuable_type} it was created from') % { issuable_type: @add_related_issue.issue_type }
+
- if issuable.respond_to?(:confidential) && can?(current_user, :set_confidentiality, issuable)
.form-group.row
.offset-sm-2.col-sm-10
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index 257ad7a8518..6b00cdc5e24 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -8,9 +8,9 @@
- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet}%{link_end} to prevent a merge request draft from merging before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft:</code>'.html_safe } ).html_safe
- remove_wip_text = (_('%{link_start}Remove the %{draft_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it\'s ready.' ) % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft</code>'.html_safe } ).html_safe
-%div{ class: div_class }
- = form.text_field :title, required: true, maxlength: 255, autofocus: true,
- autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title'), dir: 'auto'
+%div{ class: div_class, data: { testid: 'issue-title-input-field' } }
+ = form.text_field :title, required: true, aria: { required: true }, maxlength: 255, autofocus: true,
+ autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title'), dir: 'auto'
- if issuable.respond_to?(:work_in_progress?)
.form-text.text-muted
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index e5197acf06f..1babc6885c2 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -5,7 +5,7 @@
.detail-page-description.content-block
#js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, full_path: @project.full_path } }
.title-container
- %h2.title= markdown_field(issuable, :title)
+ %h1.title= markdown_field(issuable, :title)
- if issuable.description.present?
.description
.md= markdown_field(issuable, :description)
diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml
index cfc00bd41ca..bb582b159ba 100644
--- a/app/views/shared/labels/_sort_dropdown.html.haml
+++ b/app/views/shared/labels/_sort_dropdown.html.haml
@@ -1,9 +1,3 @@
-- sort_title = label_sort_options_hash[@sort] || sort_title_name_desc
-.dropdown.inline
- %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } }
- = sort_title
- = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
- %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort
- %li
- - label_sort_options_hash.each do |value, title|
- = sortable_item(title, page_filter_path(sort: value), sort_title)
+- label_sort_options = label_sort_options_hash.map { |value, text| { value: value, text: text, href: page_filter_path(sort: value) } }
+
+= gl_redirect_listbox_tag label_sort_options, @sort, data: { right: true }
diff --git a/app/views/shared/members/_invite_group.html.haml b/app/views/shared/members/_invite_group.html.haml
deleted file mode 100644
index cefdf825eaa..00000000000
--- a/app/views/shared/members/_invite_group.html.haml
+++ /dev/null
@@ -1,30 +0,0 @@
-- access_levels = local_assigns[:access_levels]
-- default_access_level = local_assigns[:default_access_level]
-- submit_url = local_assigns[:submit_url]
-- group_link_field = local_assigns[:group_link_field]
-- group_access_field = local_assigns[:group_access_field]
-- groups_select_tag_data = local_assigns[:groups_select_tag_data]
-
-.row
- .col-sm-12
- = form_tag submit_url, class: 'invite-group-form js-requires-input', method: :post do
- .form-group
- = label_tag group_link_field, _("Select a group to invite"), class: "label-bold"
- = groups_select_tag(group_link_field, data: groups_select_tag_data, class: 'input-clamp qa-group-select-field', required: true)
- .form-text.text-muted.gl-mb-3
- = _('Group sharing provides access to all group members (including members who inherited group membership from a parent group).')
- .form-group
- = label_tag group_access_field, _("Max role"), class: "label-bold"
- .select-wrapper
- = select_tag group_access_field, options_for_select(access_levels, default_access_level), data: { qa_selector: 'group_access_field' }, class: "form-control select-control"
- = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200")
- .form-text.text-muted.gl-mb-3
- - permissions_docs_path = help_page_path('user/permissions')
- - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path }
- = _("%{link_start}Learn more%{link_end} about roles.").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- .form-group
- = label_tag :expires_at, _('Access expiration date'), class: 'label-bold'
- .clearable-input
- = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: _('Expiration date'), id: 'expires_at_groups'
- = sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input gl-text-gray-200')
- = submit_tag _("Invite"), class: "gl-button btn btn-confirm gl-mr-3", data: { qa_selector: 'invite_group_button' }
diff --git a/app/views/shared/members/_invite_member.html.haml b/app/views/shared/members/_invite_member.html.haml
deleted file mode 100644
index e6863ed56a5..00000000000
--- a/app/views/shared/members/_invite_member.html.haml
+++ /dev/null
@@ -1,28 +0,0 @@
-- access_levels = local_assigns[:access_levels]
-- default_access_level = local_assigns[:default_access_level]
-- submit_url = local_assigns[:submit_url]
-- can_import_members = local_assigns[:can_import_members?]
-- import_path = local_assigns[:import_path]
-.row
- .col-sm-12
- = form_tag submit_url, class: 'invite-users-form', data: { testid: 'invite-users-form' }, method: :post do
- .form-group
- = label_tag :user_ids, _("GitLab member or Email address"), class: "label-bold"
- = users_select_tag(:user_ids, multiple: true, class: 'input-clamp qa-member-select-field', scope: :all, email_user: true, placeholder: 'Search for members to update or invite')
- .form-group
- = label_tag :access_level, _("Select a role"), class: "label-bold"
- .select-wrapper
- = select_tag :access_level, options_for_select(access_levels, default_access_level), class: "form-control project-access-select select-control"
- = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200")
- .form-text.text-muted.gl-mb-3
- - permissions_docs_path = help_page_path('user/permissions')
- - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path }
- = _("%{link_start}Learn more%{link_end} about roles.").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- .form-group
- = label_tag :expires_at, _('Access expiration date'), class: 'label-bold'
- .clearable-input
- = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
- = sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input gl-text-gray-200')
- = submit_tag _("Invite"), class: "gl-button btn btn-confirm gl-mr-2", data: { qa_selector: 'invite_member_button' }
- - if can_import_members
- = link_to _("Import"), import_path, class: "gl-button btn btn-default", title: _("Import members from another project")
diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml
index 6d4ff255f06..8a709a36835 100644
--- a/app/views/shared/milestones/_delete_button.html.haml
+++ b/app/views/shared/milestones/_delete_button.html.haml
@@ -6,7 +6,7 @@
milestone_issue_count: @milestone.issues.count,
milestone_merge_request_count: @milestone.merge_requests.count },
disabled: true }
+ = gl_loading_icon(inline: true, css_class: "gl-mr-2 js-loading-icon hidden")
= _('Delete')
- .gl-spinner.js-loading-icon.hidden
#js-delete-milestone-modal
diff --git a/app/views/shared/milestones/_milestone_complete_alert.html.haml b/app/views/shared/milestones/_milestone_complete_alert.html.haml
index 1c25fae747e..5b05fdb6019 100644
--- a/app/views/shared/milestones/_milestone_complete_alert.html.haml
+++ b/app/views/shared/milestones/_milestone_complete_alert.html.haml
@@ -3,7 +3,6 @@
- if milestone.complete? && milestone.active?
= render 'shared/global_alert',
variant: :success,
- is_contained: true,
alert_data: { testid: 'all-issues-closed-alert' },
dismissible: false do
.gl-alert-body
diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml
index b19e994ef80..ebd4ef7d4c3 100644
--- a/app/views/shared/milestones/_tab_loading.html.haml
+++ b/app/views/shared/milestones/_tab_loading.html.haml
@@ -1,2 +1 @@
-.text-center.gl-mt-3
- .gl-spinner.gl-spinner-md
+= gl_loading_icon(size: 'md', css_class: 'gl-mt-3')
diff --git a/app/views/shared/nav/_sidebar_submenu.html.haml b/app/views/shared/nav/_sidebar_submenu.html.haml
index 750e6c9ee57..344dafe7c0f 100644
--- a/app/views/shared/nav/_sidebar_submenu.html.haml
+++ b/app/views/shared/nav/_sidebar_submenu.html.haml
@@ -4,7 +4,7 @@
%strong.fly-out-top-item-name
= sidebar_menu.title
- if sidebar_menu.has_pill?
- %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options }
+ = gl_badge_tag({ variant: :info, size: :sm }, { class: "count fly-out-badge #{sidebar_menu.pill_html_options[:class]}" }) do
= number_with_delimiter(sidebar_menu.pill_count)
- if sidebar_menu.has_renderable_items?
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index 6c8b2a9e5bb..8a79a17b166 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -18,7 +18,7 @@
%span.attaching-file-message
-# Populated by app/assets/javascripts/dropzone_input.js
%span.uploading-progress 0%
- = loading_icon(css_class: 'align-text-bottom gl-mr-2')
+ = gl_loading_icon(inline: true, css_class: 'gl-mr-2')
%span.uploading-error-container.hide
%span.uploading-error-icon
diff --git a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
index 3cbe35e5c15..32b9044c551 100644
--- a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
+++ b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
@@ -34,4 +34,7 @@
= _('Members of %{group} can also push to this branch: %{branch}') % { group: (group_push_access_levels.size > 1 ? 'these groups' : 'this group'), branch: group_push_access_levels.map(&:humanize).to_sentence }
%td
- = render "shared/buttons/project_feature_toggle", is_checked: protected_branch.allow_force_push, label: s_("ProtectedBranch|Toggle allowed to force push"), class_list: "js-force-push-toggle project-feature-toggle", data: { qa_selector: 'force_push_toggle_button', qa_branch_name: protected_branch.name }
+ = render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle',
+ label: s_("ProtectedBranch|Toggle allowed to force push"),
+ is_checked: protected_branch.allow_force_push,
+ label_position: :hidden)
diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml
index 23010b8349c..03f373783f8 100644
--- a/app/views/shared/web_hooks/_hook_errors.html.haml
+++ b/app/views/shared/web_hooks/_hook_errors.html.haml
@@ -13,7 +13,6 @@
= render 'shared/global_alert',
title: s_('Webhooks|Webhook was automatically disabled'),
variant: :danger,
- is_contained: true,
close_button_class: 'js-close' do
.gl-alert-body
= s_('Webhooks|The webhook was triggered more than %{limit} times per minute and is now disabled. To re-enable this webhook, fix the problems shown in %{strong_start}Recent events%{strong_end}, then re-test your settings. %{support_link_start}Contact Support%{support_link_end} if you need help re-enabling your webhook.').html_safe % placeholders
@@ -21,7 +20,6 @@
= render 'shared/global_alert',
title: s_('Webhooks|Webhook failed to connect'),
variant: :danger,
- is_contained: true,
close_button_class: 'js-close' do
.gl-alert-body
= s_('Webhooks|The webhook failed to connect, and is disabled. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % { strong_start: strong_start, strong_end: strong_end }
@@ -35,7 +33,6 @@
= render 'shared/global_alert',
title: s_('Webhooks|Webhook fails to connect'),
variant: :warning,
- is_contained: true,
close_button_class: 'js-close' do
.gl-alert-body
= s_('Webhooks|The webhook %{help_link_start}failed to connect%{help_link_end}, and will retry in %{retry_time}. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % placeholders
diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml
index 0a8ca309823..abe7753b9f1 100644
--- a/app/views/shared/wikis/pages.html.haml
+++ b/app/views/shared/wikis/pages.html.haml
@@ -1,8 +1,8 @@
- 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])
- add_page_specific_style 'page_bundles/wiki'
+- wiki_sort_options = [{ text: s_("Wiki|Title"), value: 'title', href: wiki_path(@wiki, action: :pages, sort: Wiki::TITLE_ORDER)}, { text: s_("Wiki|Created date"), value: 'created_at', href: wiki_path(@wiki, action: :pages, sort: Wiki::CREATED_AT_ORDER) }]
.wiki-page-header.top-area.flex-column.flex-lg-row
%h3.page-title.gl-flex-grow-1
@@ -15,14 +15,7 @@
.dropdown.inline.wiki-sort-dropdown
.btn-group{ role: 'group' }
- .btn-group{ role: 'group' }
- %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn gl-button btn-default' }
- = sort_title
- = sprite_icon('chevron-down')
- %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
- %li
- = sortable_item(s_("Wiki|Title"), wiki_path(@wiki, action: :pages, sort: Wiki::TITLE_ORDER), sort_title)
- = sortable_item(s_("Wiki|Created date"), wiki_path(@wiki, action: :pages, sort: Wiki::CREATED_AT_ORDER), sort_title)
+ = gl_redirect_listbox_tag wiki_sort_options, params[:sort], data: { right: true }
= wiki_sort_controls(@wiki, params[:sort], params[:direction])
%ul.wiki-pages-list.content-list
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index c0a6ab44a26..a7875f9b089 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -3,7 +3,7 @@
.row.d-none.d-sm-flex
.col-12.calendar-block.gl-my-3
.user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: local_timezone_instance(@user.timezone).now.utc_offset } }
- .gl-spinner.gl-spinner-md.gl-my-8
+ = gl_loading_icon(size: 'md', css_class: 'gl-my-8')
.user-calendar-error.invisible
= _('There was an error loading users activity calendar.')
%a.js-retry-load{ href: '#' }
@@ -35,8 +35,7 @@
= Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity')
= link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all"
.overview-content-list{ data: { href: user_activity_path, qa_selector: 'user_activity_content' } }
- .center.light.loading
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md', css_class: 'loading')
- unless Feature.enabled?(:security_auto_fix) && @user.bot?
.col-md-12.col-lg-6
@@ -47,5 +46,4 @@
= s_('UserProfile|Personal projects')
= link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
.overview-content-list{ data: { href: user_projects_path } }
- .center.light.loading
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md', css_class: 'loading')
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index fb1fcb7937c..48bdee4062b 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -309,6 +309,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:database_batched_background_migration_ci_database
+ :worker_name: Database::BatchedBackgroundMigration::CiDatabaseWorker
+ :feature_category: :database
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:database_drop_detached_partitions
:worker_name: Database::DropDetachedPartitionsWorker
:feature_category: :database
@@ -552,6 +561,15 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:projects_schedule_refresh_build_artifacts_size_statistics
+ :worker_name: Projects::ScheduleRefreshBuildArtifactsSizeStatisticsWorker
+ :feature_category: :build_artifacts
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:prune_old_events
:worker_name: PruneOldEventsWorker
:feature_category: :users
@@ -561,6 +579,15 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:quality_test_data_cleanup
+ :worker_name: Quality::TestDataCleanupWorker
+ :feature_category: :quality_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:releases_manage_evidence
:worker_name: Releases::ManageEvidenceWorker
:feature_category: :release_evidence
@@ -1654,7 +1681,7 @@
:worker_name: Ci::DropPipelineWorker
:feature_category: :continuous_integration
:has_external_dependencies:
- :urgency: :low
+ :urgency: :high
:resource_boundary: :unknown
:weight: 3
:idempotent: true
@@ -2785,6 +2812,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: projects_refresh_build_artifacts_size_statistics
+ :worker_name: Projects::RefreshBuildArtifactsSizeStatisticsWorker
+ :feature_category: :build_artifacts
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: projects_schedule_bulk_repository_shard_moves
:worker_name: Projects::ScheduleBulkRepositoryShardMovesWorker
:feature_category: :gitaly
diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb
index 8bc0acc9b22..21040178cee 100644
--- a/app/workers/bulk_imports/export_request_worker.rb
+++ b/app/workers/bulk_imports/export_request_worker.rb
@@ -14,6 +14,10 @@ module BulkImports
entity = BulkImports::Entity.find(entity_id)
request_export(entity)
+ rescue BulkImports::NetworkError => e
+ log_export_failure(e, entity)
+
+ entity.fail_op!
end
private
@@ -28,5 +32,24 @@ module BulkImports
token: configuration.access_token
)
end
+
+ def log_export_failure(exception, entity)
+ attributes = {
+ bulk_import_entity_id: entity.id,
+ pipeline_class: 'ExportRequestWorker',
+ exception_class: exception.class.to_s,
+ exception_message: exception.message.truncate(255),
+ correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id
+ }
+
+ Gitlab::Import::Logger.warn(
+ attributes.merge(
+ bulk_import_id: entity.bulk_import.id,
+ bulk_import_entity_type: entity.source_type
+ )
+ )
+
+ BulkImports::Failure.create(attributes)
+ end
end
end
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
index 8e5d7013c2c..03ec2f058ca 100644
--- a/app/workers/bulk_imports/pipeline_worker.rb
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -43,6 +43,10 @@ module BulkImports
private
def run(pipeline_tracker)
+ if pipeline_tracker.entity.failed?
+ raise(Entity::FailedError, 'Failed entity status')
+ end
+
if ndjson_pipeline?(pipeline_tracker)
status = ExportStatus.new(pipeline_tracker, pipeline_tracker.pipeline_class.relation)
diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb
index 56cfaa7e674..70c234bd4c7 100644
--- a/app/workers/ci/build_finished_worker.rb
+++ b/app/workers/ci/build_finished_worker.rb
@@ -39,6 +39,7 @@ module Ci
# We execute these async as these are independent operations.
BuildHooksWorker.perform_async(build.id)
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
+ build.track_deployment_usage
if build.failed? && !build.auto_retry_expected?
::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id)
diff --git a/app/workers/ci/drop_pipeline_worker.rb b/app/workers/ci/drop_pipeline_worker.rb
index edb97c3cac5..6018290b3a2 100644
--- a/app/workers/ci/drop_pipeline_worker.rb
+++ b/app/workers/ci/drop_pipeline_worker.rb
@@ -9,6 +9,8 @@ module Ci
sidekiq_options retry: 3
include PipelineQueue
+ urgency :high
+
idempotent!
def perform(pipeline_id, failure_reason)
diff --git a/app/workers/concerns/git_garbage_collect_methods.rb b/app/workers/concerns/git_garbage_collect_methods.rb
index c46deeb716f..13b7e7b5b1f 100644
--- a/app/workers/concerns/git_garbage_collect_methods.rb
+++ b/app/workers/concerns/git_garbage_collect_methods.rb
@@ -83,17 +83,27 @@ module GitGarbageCollectMethods
def gitaly_call(task, resource)
repository = resource.repository.raw_repository
- client = get_gitaly_client(task, repository)
-
- case task
- when :prune, :gc
- client.garbage_collect(bitmaps_enabled?, prune: task == :prune)
- when :full_repack
- client.repack_full(bitmaps_enabled?)
- when :incremental_repack
- client.repack_incremental
- when :pack_refs
- client.pack_refs
+ if Feature.enabled?(:optimized_housekeeping, container(resource), default_enabled: :yaml)
+ client = repository.gitaly_repository_client
+
+ if task == :prune
+ client.prune_unreachable_objects
+ else
+ client.optimize_repository
+ end
+ else
+ client = get_gitaly_client(task, repository)
+
+ case task
+ when :prune, :gc
+ client.garbage_collect(bitmaps_enabled?, prune: task == :prune)
+ when :full_repack
+ client.repack_full(bitmaps_enabled?)
+ when :incremental_repack
+ client.repack_incremental
+ when :pack_refs
+ client.pack_refs
+ end
end
rescue GRPC::NotFound => e
Gitlab::GitLogger.error("#{__method__} failed:\nRepository not found")
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 7f7a77d0524..cd3ed5d4c9b 100644
--- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
+++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
@@ -123,7 +123,7 @@ module ContainerExpirationPolicies
end
def throttling_enabled?
- Feature.enabled?(:container_registry_expiration_policies_throttling)
+ Feature.enabled?(:container_registry_expiration_policies_throttling, default_enabled: :yaml)
end
def max_cleanup_execution_time
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
index 16ac61976eb..308ccfe2cb3 100644
--- a/app/workers/container_expiration_policy_worker.rb
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -99,7 +99,7 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
end
def throttling_enabled?
- Feature.enabled?(:container_registry_expiration_policies_throttling)
+ Feature.enabled?(:container_registry_expiration_policies_throttling, default_enabled: :yaml)
end
def lease_timeout
diff --git a/app/workers/database/batched_background_migration/ci_database_worker.rb b/app/workers/database/batched_background_migration/ci_database_worker.rb
new file mode 100644
index 00000000000..98ec6f98123
--- /dev/null
+++ b/app/workers/database/batched_background_migration/ci_database_worker.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+module Database
+ module BatchedBackgroundMigration
+ class CiDatabaseWorker # rubocop:disable Scalability/IdempotentWorker
+ include SingleDatabaseWorker
+
+ def self.tracking_database
+ @tracking_database ||= Gitlab::Database::CI_DATABASE_NAME
+ end
+ end
+ end
+end
diff --git a/app/workers/database/batched_background_migration/single_database_worker.rb b/app/workers/database/batched_background_migration/single_database_worker.rb
new file mode 100644
index 00000000000..78c82a6549f
--- /dev/null
+++ b/app/workers/database/batched_background_migration/single_database_worker.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+module Database
+ module BatchedBackgroundMigration
+ module SingleDatabaseWorker
+ extend ActiveSupport::Concern
+
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ LEASE_TIMEOUT_MULTIPLIER = 3
+ MINIMUM_LEASE_TIMEOUT = 10.minutes.freeze
+ INTERVAL_VARIANCE = 5.seconds.freeze
+
+ included do
+ data_consistency :always
+ feature_category :database
+ idempotent!
+ end
+
+ class_methods do
+ # :nocov:
+ def tracking_database
+ raise NotImplementedError, "#{self.name} does not implement #{__method__}"
+ end
+ # :nocov:
+
+ def lease_key
+ name.demodulize.underscore
+ end
+ end
+
+ def perform
+ unless base_model
+ Sidekiq.logger.info(
+ class: self.class.name,
+ database: self.class.tracking_database,
+ message: 'skipping migration execution for unconfigured database')
+
+ return
+ end
+
+ Gitlab::Database::SharedModel.using_connection(base_model.connection) do
+ break unless Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops, default_enabled: :yaml) && active_migration
+
+ with_exclusive_lease(active_migration.interval) do
+ # Now that we have the exclusive lease, reload migration in case another process has changed it.
+ # This is a temporary solution until we have better concurrency handling around job execution
+ #
+ # We also have to disable this cop, because ApplicationRecord aliases reset to reload, but our database
+ # models don't inherit from ApplicationRecord
+ active_migration.reload # rubocop:disable Cop/ActiveRecordAssociationReload
+
+ run_active_migration if active_migration.active? && active_migration.interval_elapsed?(variance: INTERVAL_VARIANCE)
+ end
+ end
+ end
+
+ private
+
+ def active_migration
+ @active_migration ||= Gitlab::Database::BackgroundMigration::BatchedMigration.active_migration
+ end
+
+ def run_active_migration
+ Gitlab::Database::BackgroundMigration::BatchedMigrationRunner.new(connection: base_model.connection).run_migration_job(active_migration)
+ end
+
+ def base_model
+ @base_model ||= Gitlab::Database.database_base_models[self.class.tracking_database]
+ end
+
+ def with_exclusive_lease(interval)
+ timeout = [interval * LEASE_TIMEOUT_MULTIPLIER, MINIMUM_LEASE_TIMEOUT].max
+ lease = Gitlab::ExclusiveLease.new(self.class.lease_key, timeout: timeout)
+
+ yield if lease.try_obtain
+ ensure
+ lease&.cancel
+ end
+ end
+ end
+end
diff --git a/app/workers/database/batched_background_migration_worker.rb b/app/workers/database/batched_background_migration_worker.rb
index fda539b372d..29804be832d 100644
--- a/app/workers/database/batched_background_migration_worker.rb
+++ b/app/workers/database/batched_background_migration_worker.rb
@@ -1,56 +1,11 @@
# frozen_string_literal: true
module Database
- class BatchedBackgroundMigrationWorker
- include ApplicationWorker
+ class BatchedBackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
+ include BatchedBackgroundMigration::SingleDatabaseWorker
- data_consistency :always
-
- include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
-
- feature_category :database
- idempotent!
-
- LEASE_TIMEOUT_MULTIPLIER = 3
- MINIMUM_LEASE_TIMEOUT = 10.minutes.freeze
- INTERVAL_VARIANCE = 5.seconds.freeze
-
- def perform
- return unless Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops, default_enabled: :yaml) && active_migration
-
- with_exclusive_lease(active_migration.interval) do
- # Now that we have the exclusive lease, reload migration in case another process has changed it.
- # This is a temporary solution until we have better concurrency handling around job execution
- #
- # We also have to disable this cop, because ApplicationRecord aliases reset to reload, but our database
- # models don't inherit from ApplicationRecord
- active_migration.reload # rubocop:disable Cop/ActiveRecordAssociationReload
-
- run_active_migration if active_migration.active? && active_migration.interval_elapsed?(variance: INTERVAL_VARIANCE)
- end
- end
-
- private
-
- def active_migration
- @active_migration ||= Gitlab::Database::BackgroundMigration::BatchedMigration.active_migration
- end
-
- def run_active_migration
- Gitlab::Database::BackgroundMigration::BatchedMigrationRunner.new.run_migration_job(active_migration)
- end
-
- def with_exclusive_lease(interval)
- timeout = [interval * LEASE_TIMEOUT_MULTIPLIER, MINIMUM_LEASE_TIMEOUT].max
- lease = Gitlab::ExclusiveLease.new(lease_key, timeout: timeout)
-
- yield if lease.try_obtain
- ensure
- lease&.cancel
- end
-
- def lease_key
- self.class.name.demodulize.underscore
+ def self.tracking_database
+ @tracking_database ||= Gitlab::Database::MAIN_DATABASE_NAME.to_sym
end
end
end
diff --git a/app/workers/projects/git_garbage_collect_worker.rb b/app/workers/projects/git_garbage_collect_worker.rb
index d16583975fc..a70c52abde2 100644
--- a/app/workers/projects/git_garbage_collect_worker.rb
+++ b/app/workers/projects/git_garbage_collect_worker.rb
@@ -7,6 +7,12 @@ module Projects
private
+ # Used for getting a project/group out of the resource in order to scope a feature flag
+ # Can be removed within https://gitlab.com/gitlab-org/gitlab/-/issues/353607
+ def container(resource)
+ resource
+ end
+
override :find_resource
def find_resource(id)
Project.find(id)
diff --git a/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb
new file mode 100644
index 00000000000..a91af72cc2c
--- /dev/null
+++ b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Projects
+ class RefreshBuildArtifactsSizeStatisticsWorker
+ include ApplicationWorker
+ include LimitedCapacity::Worker
+
+ MAX_RUNNING_LOW = 2
+ MAX_RUNNING_MEDIUM = 20
+ MAX_RUNNING_HIGH = 50
+
+ data_consistency :always
+
+ feature_category :build_artifacts
+
+ idempotent!
+
+ def perform_work(*args)
+ refresh = Projects::RefreshBuildArtifactsSizeStatisticsService.new.execute
+ return unless refresh
+
+ log_extra_metadata_on_done(:project_id, refresh.project_id)
+ log_extra_metadata_on_done(:last_job_artifact_id, refresh.last_job_artifact_id)
+ log_extra_metadata_on_done(:last_batch, refresh.destroyed?)
+ log_extra_metadata_on_done(:refresh_started_at, refresh.refresh_started_at)
+ end
+
+ def remaining_work_count(*args)
+ # LimitedCapacity::Worker only needs to know if there is work left to do
+ # so we can get by with an EXISTS query rather than a count.
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/356167
+ if Projects::BuildArtifactsSizeRefresh.remaining.any?
+ 1
+ else
+ 0
+ end
+ end
+
+ def max_running_jobs
+ if ::Feature.enabled?(:projects_build_artifacts_size_refresh_high)
+ MAX_RUNNING_HIGH
+ elsif ::Feature.enabled?(:projects_build_artifacts_size_refresh_medium)
+ MAX_RUNNING_MEDIUM
+ elsif ::Feature.enabled?(:projects_build_artifacts_size_refresh_low)
+ MAX_RUNNING_LOW
+ else
+ 0
+ end
+ end
+ end
+end
diff --git a/app/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker.rb b/app/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker.rb
new file mode 100644
index 00000000000..ed2b642d998
--- /dev/null
+++ b/app/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Projects
+ class ScheduleRefreshBuildArtifactsSizeStatisticsWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ data_consistency :always
+
+ feature_category :build_artifacts
+
+ idempotent!
+
+ def perform
+ Projects::RefreshBuildArtifactsSizeStatisticsWorker.perform_with_capacity
+ end
+ end
+end
diff --git a/app/workers/quality/test_data_cleanup_worker.rb b/app/workers/quality/test_data_cleanup_worker.rb
new file mode 100644
index 00000000000..68b36cacbbf
--- /dev/null
+++ b/app/workers/quality/test_data_cleanup_worker.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Quality
+ class TestDataCleanupWorker
+ include ApplicationWorker
+
+ data_consistency :always
+ feature_category :quality_management
+ urgency :low
+
+ include CronjobQueue
+ idempotent!
+
+ KEEP_RECENT_DATA_DAY = 3
+ GROUP_PATH_PATTERN = 'test-group-fulfillment'
+ GROUP_OWNER_EMAIL_PATTERN = %w(test-user- gitlab-qa-user qa-user-).freeze
+
+ # Remove test groups generated in E2E tests on gstg
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform
+ return unless Gitlab.staging?
+
+ Group.where('path like ?', "#{GROUP_PATH_PATTERN}%").where('created_at < ?', KEEP_RECENT_DATA_DAY.days.ago).each do |group|
+ next unless GROUP_OWNER_EMAIL_PATTERN.any? { |pattern| group.owners.first.email.include?(pattern) }
+
+ with_context(namespace: group, user: group.owners.first) do
+ Groups::DestroyService.new(group, group.owners.first).execute
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb
index fdcd22128a3..301f3720991 100644
--- a/app/workers/web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -13,9 +13,6 @@ class WebHookWorker
worker_has_external_dependencies!
- # Webhook recursion detection properties may be passed through the `data` arg.
- # This will be migrated to the `params` arg over the next few releases.
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/347389.
def perform(hook_id, data, hook_name, params = {})
hook = WebHook.find_by_id(hook_id)
return unless hook
@@ -23,9 +20,6 @@ class WebHookWorker
data = data.with_indifferent_access
params.symbolize_keys!
- # TODO: Remove in 14.9 https://gitlab.com/gitlab-org/gitlab/-/issues/347389
- params[:recursion_detection_request_uuid] ||= data.delete(:_gitlab_recursion_detection_request_uuid)
-
# Before executing the hook, reapply any recursion detection UUID that was initially
# present in the request header so the hook can pass this same header value in its request.
Gitlab::WebHooks::RecursionDetection.set_request_uuid(params[:recursion_detection_request_uuid])
diff --git a/app/workers/wikis/git_garbage_collect_worker.rb b/app/workers/wikis/git_garbage_collect_worker.rb
index 1b455c50618..b00190c6b98 100644
--- a/app/workers/wikis/git_garbage_collect_worker.rb
+++ b/app/workers/wikis/git_garbage_collect_worker.rb
@@ -7,6 +7,12 @@ module Wikis
private
+ # Used for getting a project/group out of the resource in order to scope a feature flag
+ # Can be removed within https://gitlab.com/gitlab-org/gitlab/-/issues/353607
+ def container(resource)
+ resource.container
+ end
+
override :find_resource
def find_resource(id)
Project.find(id).wiki