summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-09-19 23:18:09 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-09-19 23:18:09 +0000
commit6ed4ec3e0b1340f96b7c043ef51d1b33bbe85fde (patch)
treedc4d20fe6064752c0bd323187252c77e0a89144b /app
parent9868dae7fc0655bd7ce4a6887d4e6d487690eeed (diff)
downloadgitlab-ce-6ed4ec3e0b1340f96b7c043ef51d1b33bbe85fde.tar.gz
Add latest changes from gitlab-org/gitlab@15-4-stable-eev15.4.0-rc42
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/access_tokens/components/access_token_table_app.vue1
-rw-r--r--app/assets/javascripts/access_tokens/components/constants.js14
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue29
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue10
-rw-r--r--app/assets/javascripts/access_tokens/index.js4
-rw-r--r--app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue52
-rw-r--r--app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue123
-rw-r--r--app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js32
-rw-r--r--app/assets/javascripts/admin/topics/components/merge_topics.vue141
-rw-r--r--app/assets/javascripts/admin/topics/components/topic_select.vue106
-rw-r--r--app/assets/javascripts/admin/topics/index.js32
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_form.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/components/daterange.vue24
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js3
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/utils.js2
-rw-r--r--app/assets/javascripts/api.js1
-rw-r--r--app/assets/javascripts/api/harbor_registry.js49
-rw-r--r--app/assets/javascripts/api/integrations_api.js21
-rw-r--r--app/assets/javascripts/api/user_api.js6
-rw-r--r--app/assets/javascripts/autosave.js16
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_item.vue1
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue87
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js6
-rw-r--r--app/assets/javascripts/behaviors/copy_code.js1
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js8
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js30
-rw-r--r--app/assets/javascripts/blob/3d_viewer/index.js7
-rw-r--r--app/assets/javascripts/blob/3d_viewer/mesh_object.js2
-rw-r--r--app/assets/javascripts/blob/notebook/notebook_viewer.vue6
-rw-r--r--app/assets/javascripts/blob/sketch/index.js41
-rw-r--r--app/assets/javascripts/blob/viewer/index.js1
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js3
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_blocked_icon.vue27
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue19
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue47
-rw-r--r--app/assets/javascripts/boards/components/board_card_move_to_position.vue128
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue22
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue16
-rw-r--r--app/assets/javascripts/boards/components/board_new_item.vue2
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue8
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/components/item_count.vue4
-rw-r--r--app/assets/javascripts/boards/constants.js6
-rw-r--r--app/assets/javascripts/boards/filters/due_date_filters.js2
-rw-r--r--app/assets/javascripts/boards/graphql/board.fragment.graphql4
-rw-r--r--app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql17
-rw-r--r--app/assets/javascripts/boards/graphql/group_boards.query.graphql5
-rw-r--r--app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql5
-rw-r--r--app/assets/javascripts/boards/graphql/project_boards.query.graphql5
-rw-r--r--app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql5
-rw-r--r--app/assets/javascripts/boards/stores/actions.js28
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js41
-rw-r--r--app/assets/javascripts/boards/stores/state.js1
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue4
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue4
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue120
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue18
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue2
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js10
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql3
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql11
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql15
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/resolvers.js56
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js9
-rw-r--r--app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue66
-rw-r--r--app/assets/javascripts/clusters/agents/components/integration_status.vue98
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue19
-rw-r--r--app/assets/javascripts/clusters/agents/components/token_table.vue7
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js22
-rw-r--r--app/assets/javascripts/clusters_list/clusters_util.js23
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue34
-rw-r--r--app/assets/javascripts/code_navigation/utils/dom_utils.js1
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue2
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/dropdown.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue60
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue (renamed from app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue)13
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue (renamed from app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue)11
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue (renamed from app/assets/javascripts/content_editor/components/bubble_menus/link.vue)47
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue (renamed from app/assets/javascripts/content_editor/components/bubble_menus/media.vue)5
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue84
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_alert.vue17
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue14
-rw-r--r--app/assets/javascripts/content_editor/components/loading_indicator.vue34
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_image_button.vue48
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_link_button.vue55
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue13
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue24
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue2
-rw-r--r--app/assets/javascripts/content_editor/constants/index.js8
-rw-r--r--app/assets/javascripts/content_editor/content_editor.stories.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js14
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js8
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js41
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js2
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js16
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js70
-rw-r--r--app/assets/javascripts/crm/constants.js4
-rw-r--r--app/assets/javascripts/crm/organizations/bundle.js18
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql29
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql11
-rw-r--r--app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue2
-rw-r--r--app/assets/javascripts/crm/organizations/components/organizations_root.vue225
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time.vue2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue5
-rw-r--r--app/assets/javascripts/cycle_analytics/store/getters.js2
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue4
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue4
-rw-r--r--app/assets/javascripts/deploy_keys/index.js4
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/render.js2
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue5
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue5
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue40
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue2
-rw-r--r--app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue4
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue1
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue12
-rw-r--r--app/assets/javascripts/diffs/components/app.vue19
-rw-r--r--app/assets/javascripts/diffs/components/compare_dropdown_layout.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_gutter_avatars.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_line.vue35
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue17
-rw-r--r--app/assets/javascripts/diffs/constants.js3
-rw-r--r--app/assets/javascripts/diffs/i18n.js2
-rw-r--r--app/assets/javascripts/diffs/index.js4
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue4
-rw-r--r--app/assets/javascripts/environments/components/deployment.vue171
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue1
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js4
-rw-r--r--app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql13
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue1
-rw-r--r--app/assets/javascripts/filterable_list.js1
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue12
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js1
-rw-r--r--app/assets/javascripts/filtered_search/droplab/drop_down.js4
-rw-r--r--app/assets/javascripts/filtered_search/droplab/hook_button.js1
-rw-r--r--app/assets/javascripts/filtered_search/droplab/hook_input.js1
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js5
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js2
-rw-r--r--app/assets/javascripts/flash.js2
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list.vue2
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue9
-rw-r--r--app/assets/javascripts/google_cloud/databases/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/databases/init_index.js11
-rw-r--r--app/assets/javascripts/google_cloud/databases/init_new.js11
-rw-r--r--app/assets/javascripts/google_cloud/databases/panel.vue38
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js19
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql13
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql13
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js (renamed from app/assets/javascripts/work_items/graphql/provider.js)40
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json1
-rw-r--r--app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql (renamed from app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql)0
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search.query.graphql14
-rw-r--r--app/assets/javascripts/groups/components/app.vue13
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue22
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue8
-rw-r--r--app/assets/javascripts/groups/components/overview_tabs.vue103
-rw-r--r--app/assets/javascripts/groups/components/visibility_level_dropdown.vue48
-rw-r--r--app/assets/javascripts/groups/constants.js26
-rw-r--r--app/assets/javascripts/groups/index.js17
-rw-r--r--app/assets/javascripts/groups/init_overview_tabs.js78
-rw-r--r--app/assets/javascripts/groups/visibility_level.js24
-rw-r--r--app/assets/javascripts/header_search/constants.js25
-rw-r--r--app/assets/javascripts/header_search/store/actions.js29
-rw-r--r--app/assets/javascripts/header_search/store/getters.js18
-rw-r--r--app/assets/javascripts/header_search/store/mutations.js8
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue4
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue4
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue6
-rw-r--r--app/assets/javascripts/ide/index.js14
-rw-r--r--app/assets/javascripts/ide/init_gitlab_web_ide.js30
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js1
-rw-r--r--app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js1
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue31
-rw-r--r--app/assets/javascripts/invite_members/components/user_limit_notification.vue23
-rw-r--r--app/assets/javascripts/invite_members/constants.js11
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js9
-rw-r--r--app/assets/javascripts/issuable/components/issue_assignees.vue2
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue4
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue2
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js71
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue52
-rw-r--r--app/assets/javascripts/issues/list/constants.js19
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue27
-rw-r--r--app/assets/javascripts/issues/show/components/delete_issue_modal.vue3
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue10
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue86
-rw-r--r--app/assets/javascripts/issues/show/components/edited.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue17
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/constants.js11
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue31
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue47
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql13
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue242
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue94
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue66
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue27
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js11
-rw-r--r--app/assets/javascripts/issues/show/graphql.js2
-rw-r--r--app/assets/javascripts/issues/show/index.js2
-rw-r--r--app/assets/javascripts/issues/show/utils/update_description.js1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/api.js64
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue15
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue111
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/constants.js25
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue38
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue12
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/actions.js8
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/state.js8
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/utils.js41
-rw-r--r--app/assets/javascripts/jobs/components/filtered_search/constants.js13
-rw-r--r--app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue22
-rw-r--r--app/assets/javascripts/jobs/components/filtered_search/utils.js27
-rw-r--r--app/assets/javascripts/jobs/components/job/empty_state.vue (renamed from app/assets/javascripts/jobs/components/empty_state.vue)16
-rw-r--r--app/assets/javascripts/jobs/components/job/environments_block.vue (renamed from app/assets/javascripts/jobs/components/environments_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/erased_block.vue (renamed from app/assets/javascripts/jobs/components/erased_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/job_app.vue (renamed from app/assets/javascripts/jobs/components/job_app.vue)8
-rw-r--r--app/assets/javascripts/jobs/components/job/job_log_controllers.vue (renamed from app/assets/javascripts/jobs/components/job_log_controllers.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue (renamed from app/assets/javascripts/jobs/components/manual_variables_form.vue)23
-rw-r--r--app/assets/javascripts/jobs/components/job/manual_variables_form.vue195
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue (renamed from app/assets/javascripts/jobs/components/artifacts_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue (renamed from app/assets/javascripts/jobs/components/commit_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue (renamed from app/assets/javascripts/jobs/components/job_container_item.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue (renamed from app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue)2
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue (renamed from app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue)4
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue (renamed from app/assets/javascripts/jobs/components/jobs_container.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue99
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue (renamed from app/assets/javascripts/jobs/components/sidebar.vue)96
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue (renamed from app/assets/javascripts/jobs/components/sidebar_detail_row.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue102
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue (renamed from app/assets/javascripts/jobs/components/sidebar_job_details_container.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue (renamed from app/assets/javascripts/jobs/components/stages_dropdown.vue)4
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue (renamed from app/assets/javascripts/jobs/components/trigger_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/stuck_block.vue (renamed from app/assets/javascripts/jobs/components/stuck_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/unmet_prerequisites_block.vue (renamed from app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql1
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue24
-rw-r--r--app/assets/javascripts/jobs/constants.js8
-rw-r--r--app/assets/javascripts/jobs/index.js2
-rw-r--r--app/assets/javascripts/labels/labels_select.js1
-rw-r--r--app/assets/javascripts/lib/dateformat.js60
-rw-r--r--app/assets/javascripts/lib/dompurify.js6
-rw-r--r--app/assets/javascripts/lib/gfm/constants.js10
-rw-r--r--app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js85
-rw-r--r--app/assets/javascripts/lib/gfm/index.js11
-rw-r--r--app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js1
-rw-r--r--app/assets/javascripts/lib/mermaid.js1
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js26
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js2
-rw-r--r--app/assets/javascripts/lib/utils/datetime_range.js2
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js10
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js1
-rw-r--r--app/assets/javascripts/linked_resources/index.js2
-rw-r--r--app/assets/javascripts/locale/sprintf.js2
-rw-r--r--app/assets/javascripts/members/components/modals/remove_member_modal.vue3
-rw-r--r--app/assets/javascripts/members/constants.js5
-rw-r--r--app/assets/javascripts/members/utils.js16
-rw-r--r--app/assets/javascripts/merge_request_tabs.js1
-rw-r--r--app/assets/javascripts/merge_requests/components/sticky_header.vue180
-rw-r--r--app/assets/javascripts/milestones/components/milestone_combobox.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue3
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/refresh_button.vue4
-rw-r--r--app/assets/javascripts/monitoring/format_date.js2
-rw-r--r--app/assets/javascripts/mr_notes/index.js6
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js4
-rw-r--r--app/assets/javascripts/nav/components/top_nav_app.vue17
-rw-r--r--app/assets/javascripts/nav/components/top_nav_menu_sections.vue29
-rw-r--r--app/assets/javascripts/notebook/cells/code.vue13
-rw-r--r--app/assets/javascripts/notebook/cells/code/index.vue25
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/image.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue6
-rw-r--r--app/assets/javascripts/notebook/index.vue6
-rw-r--r--app/assets/javascripts/notebook/lib/highlight.js5
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue18
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue8
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue93
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue109
-rw-r--r--app/assets/javascripts/notes/components/discussion_navigator.vue11
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue15
-rw-r--r--app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue49
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue18
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue12
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue26
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue25
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue18
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue36
-rw-r--r--app/assets/javascripts/notes/components/sidebar_subscription.vue2
-rw-r--r--app/assets/javascripts/notes/components/sort_discussion.vue76
-rw-r--r--app/assets/javascripts/notes/components/timeline_toggle.vue1
-rw-r--r--app/assets/javascripts/notes/constants.js2
-rw-r--r--app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql8
-rw-r--r--app/assets/javascripts/notes/index.js8
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js107
-rw-r--r--app/assets/javascripts/notes/sort_discussions.js17
-rw-r--r--app/assets/javascripts/notes/stores/actions.js55
-rw-r--r--app/assets/javascripts/notes/stores/getters.js7
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue95
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue133
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue47
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue68
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue12
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue32
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue54
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue82
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue74
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js17
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js46
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js14
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/index.js43
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js200
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue156
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue103
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue169
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/router.js13
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/utils.js84
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue19
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue103
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue79
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue129
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue112
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue70
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue32
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue39
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js41
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue7
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js4
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_previewer.js2
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/runners/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/topics/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/topics/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js6
-rw-r--r--app/assets/javascripts/pages/groups/details/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/runners/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js2
-rw-r--r--app/assets/javascripts/pages/profiles/keys/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/show/emoji_menu.js19
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js96
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue112
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue136
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql13
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/databases/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js69
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js23
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js8
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js30
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js4
-rw-r--r--app/assets/javascripts/pages/projects/project.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/merge_requests/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue189
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js17
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue81
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js2
-rw-r--r--app/assets/javascripts/pages/users/user_overview_block.js1
-rw-r--r--app/assets/javascripts/pdf/index.vue4
-rw-r--r--app/assets/javascripts/persistent_user_callout.js4
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js3
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue31
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue16
-rw-r--r--app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue490
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue2
-rw-r--r--app/assets/javascripts/pipeline_new/index.js75
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/editor.vue12
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/wrapper.vue63
-rw-r--r--app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue4
-rw-r--r--app/assets/javascripts/pipeline_wizard/templates/pages.yml1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue39
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/performance_insights_modal.vue171
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js14
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue)2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue132
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue103
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue)4
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue)7
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue33
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue42
-rw-r--r--app/assets/javascripts/pipelines/constants.js6
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql28
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql1
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js4
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js9
-rw-r--r--app/assets/javascripts/pipelines/utils.js21
-rw-r--r--app/assets/javascripts/profile/account/index.js4
-rw-r--r--app/assets/javascripts/profile/profile.js24
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue36
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue2
-rw-r--r--app/assets/javascripts/projects/project_visibility.js11
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue52
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue61
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql10
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js17
-rw-r--r--app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue2
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue32
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue64
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js2
-rw-r--r--app/assets/javascripts/projects/star.js3
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue4
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue4
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue17
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue2
-rw-r--r--app/assets/javascripts/related_issues/constants.js9
-rw-r--r--app/assets/javascripts/releases/components/evidence_block.vue2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js50
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js5
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutations.js3
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/state.js1
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue28
-rw-r--r--app/assets/javascripts/repository/components/blob_controls.vue2
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue8
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue3
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue5
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue4
-rw-r--r--app/assets/javascripts/repository/log_tree.js3
-rw-r--r--app/assets/javascripts/repository/queries/project_info.query.graphql14
-rw-r--r--app/assets/javascripts/repository/utils/commit.js4
-rw-r--r--app/assets/javascripts/rest_api.js1
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue4
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue112
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_status_cell.vue3
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_cell.vue71
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_field.vue33
-rw-r--r--app/assets/javascripts/runner/components/runner_detail.vue9
-rw-r--r--app/assets/javascripts/runner/components/runner_details.vue46
-rw-r--r--app/assets/javascripts/runner/components/runner_header.vue46
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue49
-rw-r--r--app/assets/javascripts/runner/components/runner_name.vue4
-rw-r--r--app/assets/javascripts/runner/components/runner_paused_badge.vue12
-rw-r--r--app/assets/javascripts/runner/components/runner_projects.vue41
-rw-r--r--app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue58
-rw-r--r--app/assets/javascripts/runner/components/runner_status_badge.vue26
-rw-r--r--app/assets/javascripts/runner/components/runner_tags.vue2
-rw-r--r--app/assets/javascripts/runner/components/runner_type_badge.vue21
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/paused_token_config.js6
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/status_token_config.js14
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_stats.vue22
-rw-r--r--app/assets/javascripts/runner/constants.js16
-rw-r--r--app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql3
-rw-r--r--app/assets/javascripts/runner/group_runner_show/index.js3
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue8
-rw-r--r--app/assets/javascripts/runner/runner_edit/index.js (renamed from app/assets/javascripts/runner/admin_runner_edit/index.js)6
-rw-r--r--app/assets/javascripts/runner/runner_edit/runner_edit_app.vue (renamed from app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue)2
-rw-r--r--app/assets/javascripts/runner/utils.js11
-rw-r--r--app/assets/javascripts/search/index.js4
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown.vue4
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue4
-rw-r--r--app/assets/javascripts/search/under_topbar/index.js31
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js2
-rw-r--r--app/assets/javascripts/set_status_modal/constants.js14
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_form.vue231
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue219
-rw-r--r--app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue100
-rw-r--r--app/assets/javascripts/set_status_modal/utils.js5
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/escalation_status.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue80
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue24
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js20
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql12
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue33
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue10
-rw-r--r--app/assets/javascripts/sidebar/constants.js20
-rw-r--r--app/assets/javascripts/sidebar/graphql.js29
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js6
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js11
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql6
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql6
-rw-r--r--app/assets/javascripts/snippets/components/show.vue4
-rw-r--r--app/assets/javascripts/snippets/constants.js20
-rw-r--r--app/assets/javascripts/snippets/index.js8
-rw-r--r--app/assets/javascripts/snippets/utils/blob.js4
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.vue157
-rw-r--r--app/assets/javascripts/token_access/components/token_access.vue38
-rw-r--r--app/assets/javascripts/user_popovers.js1
-rw-r--r--app/assets/javascripts/validators/input_validator.js2
-rw-r--r--app/assets/javascripts/visibility_level/constants.js24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue71
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue26
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue56
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue115
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue35
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue57
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.stories.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block_highlighted.vue72
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.stories.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/split_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/timezone_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/constants.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue116
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql1
-rw-r--r--app/assets/javascripts/webpack_non_compiled_placeholder.js1
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue30
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue84
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue86
-rw-r--r--app/assets/javascripts/work_items/components/work_item_due_date.vue257
-rw-r--r--app/assets/javascripts/work_items/components/work_item_information.vue14
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue9
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js9
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue109
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue121
-rw-r--r--app/assets/javascripts/work_items/components/work_item_state.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_title.vue9
-rw-r--r--app/assets/javascripts/work_items/components/work_item_type_icon.vue20
-rw-r--r--app/assets/javascripts/work_items/components/work_item_weight.vue162
-rw-r--r--app/assets/javascripts/work_items/constants.js30
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql10
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql37
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql4
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql36
-rw-r--r--app/assets/javascripts/work_items/index.js4
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue21
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue17
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss2
-rw-r--r--app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss8
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss4
-rw-r--r--app/assets/stylesheets/framework/diffs.scss31
-rw-r--r--app/assets/stylesheets/framework/files.scss9
-rw-r--r--app/assets/stylesheets/framework/header.scss14
-rw-r--r--app/assets/stylesheets/framework/highlight.scss3
-rw-r--r--app/assets/stylesheets/framework/layout.scss10
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss1
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss10
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss2
-rw-r--r--app/assets/stylesheets/framework/typography.scss7
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss249
-rw-r--r--app/assets/stylesheets/page_bundles/editor.scss (renamed from app/assets/stylesheets/pages/editor.scss)18
-rw-r--r--app/assets/stylesheets/page_bundles/group.scss23
-rw-r--r--app/assets/stylesheets/page_bundles/issues_show.scss69
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss87
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline_schedules.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/profiles/preferences.scss (renamed from app/assets/stylesheets/pages/profiles/preferences.scss)2
-rw-r--r--app/assets/stylesheets/page_bundles/reports.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/todos.scss32
-rw-r--r--app/assets/stylesheets/page_bundles/work_items.scss36
-rw-r--r--app/assets/stylesheets/pages/commits.scss6
-rw-r--r--app/assets/stylesheets/pages/issuable.scss55
-rw-r--r--app/assets/stylesheets/pages/issues.scss18
-rw-r--r--app/assets/stylesheets/pages/login.scss20
-rw-r--r--app/assets/stylesheets/pages/note_form.scss6
-rw-r--r--app/assets/stylesheets/pages/notes.scss15
-rw-r--r--app/assets/stylesheets/pages/search.scss12
-rw-r--r--app/assets/stylesheets/pages/settings.scss6
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss39
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss26
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss458
-rw-r--r--app/assets/stylesheets/themes/_dark.scss2
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss22
-rw-r--r--app/assets/stylesheets/themes/theme_blue.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_gray.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_green.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss66
-rw-r--r--app/assets/stylesheets/themes/theme_indigo.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_light_blue.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_light_gray.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_light_green.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_light_indigo.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_light_red.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_red.scss1
-rw-r--r--app/assets/stylesheets/utilities.scss10
-rw-r--r--app/components/layouts/horizontal_section_component.haml10
-rw-r--r--app/components/layouts/horizontal_section_component.rb22
-rw-r--r--app/components/pajamas/badge_component.html.haml6
-rw-r--r--app/components/pajamas/badge_component.rb72
-rw-r--r--app/components/pajamas/button_component.rb2
-rw-r--r--app/controllers/abuse_reports_controller.rb5
-rw-r--r--app/controllers/acme_challenges_controller.rb2
-rw-r--r--app/controllers/admin/application_settings_controller.rb28
-rw-r--r--app/controllers/admin/applications_controller.rb11
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb19
-rw-r--r--app/controllers/admin/cohorts_controller.rb17
-rw-r--r--app/controllers/admin/dashboard_controller.rb9
-rw-r--r--app/controllers/admin/hook_logs_controller.rb37
-rw-r--r--app/controllers/admin/hooks_controller.rb4
-rw-r--r--app/controllers/admin/plan_limits_controller.rb39
-rw-r--r--app/controllers/admin/runners_controller.rb10
-rw-r--r--app/controllers/admin/spam_logs_controller.rb2
-rw-r--r--app/controllers/admin/topics_controller.rb12
-rw-r--r--app/controllers/admin/users_controller.rb2
-rw-r--r--app/controllers/boards/issues_controller.rb8
-rw-r--r--app/controllers/chaos_controller.rb2
-rw-r--r--app/controllers/concerns/accepts_pending_invitations.rb7
-rw-r--r--app/controllers/concerns/dependency_proxy/group_access.rb2
-rw-r--r--app/controllers/concerns/harbor/access.rb2
-rw-r--r--app/controllers/concerns/integrations/hooks_execution.rb95
-rw-r--r--app/controllers/concerns/issuable_actions.rb5
-rw-r--r--app/controllers/concerns/membership_actions.rb6
-rw-r--r--app/controllers/concerns/packages_access.rb2
-rw-r--r--app/controllers/concerns/product_analytics_tracking.rb12
-rw-r--r--app/controllers/concerns/verifies_with_email.rb59
-rw-r--r--app/controllers/concerns/web_hooks/hook_actions.rb85
-rw-r--r--app/controllers/concerns/web_hooks/hook_execution_notice.rb20
-rw-r--r--app/controllers/concerns/web_hooks/hook_log_actions.rb44
-rw-r--r--app/controllers/groups/observability_controller.rb45
-rw-r--r--app/controllers/groups/runners_controller.rb7
-rw-r--r--app/controllers/groups/settings/applications_controller.rb12
-rw-r--r--app/controllers/groups/settings/repository_controller.rb16
-rw-r--r--app/controllers/groups_controller.rb7
-rw-r--r--app/controllers/health_controller.rb10
-rw-r--r--app/controllers/help_controller.rb23
-rw-r--r--app/controllers/ide_controller.rb1
-rw-r--r--app/controllers/import/github_controller.rb2
-rw-r--r--app/controllers/jira_connect/oauth_callbacks_controller.rb2
-rw-r--r--app/controllers/jira_connect/subscriptions_controller.rb11
-rw-r--r--app/controllers/jwt_controller.rb6
-rw-r--r--app/controllers/metrics_controller.rb2
-rw-r--r--app/controllers/oauth/applications_controller.rb11
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb2
-rw-r--r--app/controllers/projects/blame_controller.rb5
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb16
-rw-r--r--app/controllers/projects/environments_controller.rb34
-rw-r--r--app/controllers/projects/google_cloud/base_controller.rb28
-rw-r--r--app/controllers/projects/google_cloud/configuration_controller.rb2
-rw-r--r--app/controllers/projects/google_cloud/databases_controller.rb129
-rw-r--r--app/controllers/projects/google_cloud/deployments_controller.rb12
-rw-r--r--app/controllers/projects/google_cloud/gcp_regions_controller.rb6
-rw-r--r--app/controllers/projects/google_cloud/revoke_oauth_controller.rb4
-rw-r--r--app/controllers/projects/google_cloud/service_accounts_controller.rb14
-rw-r--r--app/controllers/projects/graphs_controller.rb16
-rw-r--r--app/controllers/projects/hook_logs_controller.rb27
-rw-r--r--app/controllers/projects/hooks_controller.rb4
-rw-r--r--app/controllers/projects/incidents_controller.rb3
-rw-r--r--app/controllers/projects/integrations/shimos_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb26
-rw-r--r--app/controllers/projects/jobs_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb17
-rw-r--r--app/controllers/projects/merge_requests/drafts_controller.rb30
-rw-r--r--app/controllers/projects/merge_requests_controller.rb24
-rw-r--r--app/controllers/projects/packages/package_files_controller.rb1
-rw-r--r--app/controllers/projects/pipelines/tests_controller.rb3
-rw-r--r--app/controllers/projects/pipelines_controller.rb25
-rw-r--r--app/controllers/projects/runners_controller.rb6
-rw-r--r--app/controllers/projects/settings/integration_hook_logs_controller.rb10
-rw-r--r--app/controllers/projects/settings/integrations_controller.rb13
-rw-r--r--app/controllers/projects/settings/merge_requests_controller.rb67
-rw-r--r--app/controllers/projects/settings/repository_controller.rb5
-rw-r--r--app/controllers/projects/uploads_controller.rb10
-rw-r--r--app/controllers/projects_controller.rb25
-rw-r--r--app/controllers/registrations_controller.rb22
-rw-r--r--app/controllers/repositories/git_http_client_controller.rb6
-rw-r--r--app/controllers/repositories/git_http_controller.rb2
-rw-r--r--app/controllers/search_controller.rb18
-rw-r--r--app/experiments/combined_registration_experiment.rb16
-rw-r--r--app/finders/context_commits_finder.rb11
-rw-r--r--app/finders/crm/organizations_finder.rb16
-rw-r--r--app/finders/database/batched_background_migrations_finder.rb27
-rw-r--r--app/finders/deployments_finder.rb47
-rw-r--r--app/finders/environments/environments_finder.rb7
-rw-r--r--app/finders/group_members_finder.rb2
-rw-r--r--app/finders/groups/accepting_group_transfers_finder.rb69
-rw-r--r--app/finders/groups/accepting_project_transfers_finder.rb4
-rw-r--r--app/finders/groups/base.rb17
-rw-r--r--app/finders/groups/user_groups_finder.rb12
-rw-r--r--app/finders/groups_finder.rb36
-rw-r--r--app/finders/incident_management/timeline_events_finder.rb2
-rw-r--r--app/finders/issuable_finder.rb29
-rw-r--r--app/finders/issues_finder.rb4
-rw-r--r--app/finders/merge_requests/by_approvals_finder.rb4
-rw-r--r--app/finders/merge_requests_finder.rb28
-rw-r--r--app/finders/merge_requests_finder/params.rb6
-rw-r--r--app/finders/projects_finder.rb4
-rw-r--r--app/finders/user_groups_counter.rb6
-rw-r--r--app/graphql/graphql_triggers.rb2
-rw-r--r--app/graphql/mutations/boards/issues/issue_move_list.rb28
-rw-r--r--app/graphql/mutations/ci/job/artifacts_destroy.rb38
-rw-r--r--app/graphql/mutations/ci/job_artifact/destroy.rb39
-rw-r--r--app/graphql/mutations/ci/runner/bulk_delete.rb4
-rw-r--r--app/graphql/mutations/ci/runner/update.rb46
-rw-r--r--app/graphql/mutations/custom_emoji/create.rb4
-rw-r--r--app/graphql/mutations/custom_emoji/destroy.rb4
-rw-r--r--app/graphql/mutations/dependency_proxy/group_settings/update.rb5
-rw-r--r--app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb9
-rw-r--r--app/graphql/mutations/releases/create.rb2
-rw-r--r--app/graphql/mutations/todos/restore_many.rb4
-rw-r--r--app/graphql/queries/repository/blob_info.query.graphql (renamed from app/assets/javascripts/repository/queries/blob_info.query.graphql)14
-rw-r--r--app/graphql/resolvers/ci/job_token_scope_resolver.rb4
-rw-r--r--app/graphql/resolvers/ci/runner_jobs_resolver.rb10
-rw-r--r--app/graphql/resolvers/ci/runner_owner_project_resolver.rb15
-rw-r--r--app/graphql/resolvers/ci/runner_projects_resolver.rb63
-rw-r--r--app/graphql/resolvers/ci/test_suite_resolver.rb3
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb58
-rw-r--r--app/graphql/resolvers/concerns/looks_ahead.rb8
-rw-r--r--app/graphql/resolvers/concerns/project_search_arguments.rb36
-rw-r--r--app/graphql/resolvers/concerns/search_arguments.rb39
-rw-r--r--app/graphql/resolvers/crm/organization_state_counts_resolver.rb26
-rw-r--r--app/graphql/resolvers/crm/organizations_resolver.rb6
-rw-r--r--app/graphql/resolvers/deployment_resolver.rb20
-rw-r--r--app/graphql/resolvers/deployments_resolver.rb39
-rw-r--r--app/graphql/resolvers/environments/last_deployment_resolver.rb44
-rw-r--r--app/graphql/resolvers/environments_resolver.rb4
-rw-r--r--app/graphql/resolvers/group_packages_resolver.rb10
-rw-r--r--app/graphql/resolvers/members_resolver.rb4
-rw-r--r--app/graphql/resolvers/package_details_resolver.rb10
-rw-r--r--app/graphql/resolvers/project_jobs_resolver.rb10
-rw-r--r--app/graphql/resolvers/projects/branch_rules_resolver.rb15
-rw-r--r--app/graphql/resolvers/projects_resolver.rb32
-rw-r--r--app/graphql/resolvers/work_items_resolver.rb48
-rw-r--r--app/graphql/types/base_field.rb31
-rw-r--r--app/graphql/types/branch_protections/base_access_level_type.rb22
-rw-r--r--app/graphql/types/branch_protections/merge_access_level_type.rb11
-rw-r--r--app/graphql/types/branch_protections/push_access_level_type.rb11
-rw-r--r--app/graphql/types/branch_rules/branch_protection_type.rb29
-rw-r--r--app/graphql/types/ci/config_variable_type.rb22
-rw-r--r--app/graphql/types/ci/group_variable_connection_type.rb17
-rw-r--r--app/graphql/types/ci/group_variable_type.rb17
-rw-r--r--app/graphql/types/ci/instance_variable_type.rb28
-rw-r--r--app/graphql/types/ci/job_artifact_type.rb9
-rw-r--r--app/graphql/types/ci/job_type.rb6
-rw-r--r--app/graphql/types/ci/manual_variable_type.rb12
-rw-r--r--app/graphql/types/ci/project_variable_connection_type.rb17
-rw-r--r--app/graphql/types/ci/project_variable_type.rb13
-rw-r--r--app/graphql/types/ci/runner_membership_filter_enum.rb8
-rw-r--r--app/graphql/types/ci/runner_type.rb22
-rw-r--r--app/graphql/types/ci/variable_interface.rb24
-rw-r--r--app/graphql/types/clusters/agent_type.rb2
-rw-r--r--app/graphql/types/customer_relations/contact_sort_enum.rb4
-rw-r--r--app/graphql/types/customer_relations/organization_sort_enum.rb21
-rw-r--r--app/graphql/types/customer_relations/organization_state_counts_type.rb24
-rw-r--r--app/graphql/types/customer_relations/organization_state_enum.rb8
-rw-r--r--app/graphql/types/deployment_details_type.rb15
-rw-r--r--app/graphql/types/deployment_status_enum.rb14
-rw-r--r--app/graphql/types/deployment_tag_type.rb20
-rw-r--r--app/graphql/types/deployment_type.rb69
-rw-r--r--app/graphql/types/deployments_order_by_input_type.rb24
-rw-r--r--app/graphql/types/environment_type.rb41
-rw-r--r--app/graphql/types/group_type.rb14
-rw-r--r--app/graphql/types/member_sort_enum.rb13
-rw-r--r--app/graphql/types/merge_request_type.rb9
-rw-r--r--app/graphql/types/merge_requests/detailed_merge_status_enum.rb3
-rw-r--r--app/graphql/types/mutation_type.rb6
-rw-r--r--app/graphql/types/packages/package_details_type.rb2
-rw-r--r--app/graphql/types/project_type.rb479
-rw-r--r--app/graphql/types/projects/branch_rule_type.rb33
-rw-r--r--app/graphql/types/query_type.rb2
-rw-r--r--app/graphql/types/sort_direction_enum.rb11
-rw-r--r--app/graphql/types/subscription_type.rb7
-rw-r--r--app/graphql/types/timelog_type.rb2
-rw-r--r--app/graphql/types/work_items/widgets/description_type.rb14
-rw-r--r--app/helpers/application_settings_helper.rb22
-rw-r--r--app/helpers/badges_helper.rb66
-rw-r--r--app/helpers/blob_helper.rb26
-rw-r--r--app/helpers/ci/builds_helper.rb2
-rw-r--r--app/helpers/ci/jobs_helper.rb2
-rw-r--r--app/helpers/ci/runners_helper.rb1
-rw-r--r--app/helpers/deploy_tokens_helper.rb8
-rw-r--r--app/helpers/diff_helper.rb12
-rw-r--r--app/helpers/dropdowns_helper.rb5
-rw-r--r--app/helpers/form_helper.rb4
-rw-r--r--app/helpers/groups_helper.rb9
-rw-r--r--app/helpers/ide_helper.rb3
-rw-r--r--app/helpers/issuables_helper.rb3
-rw-r--r--app/helpers/javascript_helper.rb7
-rw-r--r--app/helpers/jira_connect_helper.rb14
-rw-r--r--app/helpers/kerberos_helper.rb (renamed from app/helpers/kerberos_spnego_helper.rb)6
-rw-r--r--app/helpers/labels_helper.rb2
-rw-r--r--app/helpers/learn_gitlab_helper.rb25
-rw-r--r--app/helpers/merge_requests_helper.rb20
-rw-r--r--app/helpers/nav/new_dropdown_helper.rb2
-rw-r--r--app/helpers/nav/top_nav_helper.rb101
-rw-r--r--app/helpers/notify_helper.rb11
-rw-r--r--app/helpers/packages_helper.rb4
-rw-r--r--app/helpers/page_layout_helper.rb4
-rw-r--r--app/helpers/profiles_helper.rb2
-rw-r--r--app/helpers/projects/google_cloud/cloudsql_helper.rb55
-rw-r--r--app/helpers/projects/pages_helper.rb11
-rw-r--r--app/helpers/projects/pipeline_helper.rb1
-rw-r--r--app/helpers/projects_helper.rb12
-rw-r--r--app/helpers/search_helper.rb40
-rw-r--r--app/helpers/sorting_helper.rb4
-rw-r--r--app/helpers/sorting_titles_values_helper.rb16
-rw-r--r--app/helpers/storage_helper.rb115
-rw-r--r--app/helpers/system_note_helper.rb6
-rw-r--r--app/helpers/timeboxes_helper.rb11
-rw-r--r--app/helpers/todos_helper.rb10
-rw-r--r--app/helpers/users/callouts_helper.rb20
-rw-r--r--app/helpers/users_helper.rb13
-rw-r--r--app/helpers/web_hooks/web_hooks_helper.rb5
-rw-r--r--app/mailers/abuse_report_mailer.rb2
-rw-r--r--app/mailers/application_mailer.rb19
-rw-r--r--app/mailers/email_rejection_mailer.rb2
-rw-r--r--app/mailers/emails/admin_notification.rb4
-rw-r--r--app/mailers/emails/groups.rb2
-rw-r--r--app/mailers/emails/identity_verification.rb2
-rw-r--r--app/mailers/emails/in_product_marketing.rb2
-rw-r--r--app/mailers/emails/members.rb2
-rw-r--r--app/mailers/emails/pages_domains.rb10
-rw-r--r--app/mailers/emails/profile.rb20
-rw-r--r--app/mailers/emails/projects.rb25
-rw-r--r--app/mailers/emails/releases.rb2
-rw-r--r--app/mailers/emails/remote_mirrors.rb2
-rw-r--r--app/mailers/notify.rb14
-rw-r--r--app/mailers/previews/notify_preview.rb18
-rw-r--r--app/mailers/repository_check_mailer.rb2
-rw-r--r--app/models/active_session.rb8
-rw-r--r--app/models/application_setting.rb17
-rw-r--r--app/models/ci/bridge.rb8
-rw-r--r--app/models/ci/build.rb137
-rw-r--r--app/models/ci/build_metadata.rb3
-rw-r--r--app/models/ci/freeze_period_status.rb28
-rw-r--r--app/models/ci/job_artifact.rb40
-rw-r--r--app/models/ci/job_token/scope.rb12
-rw-r--r--app/models/ci/namespace_mirror.rb14
-rw-r--r--app/models/ci/partition.rb6
-rw-r--r--app/models/ci/pipeline.rb81
-rw-r--r--app/models/ci/pipeline_artifact.rb6
-rw-r--r--app/models/ci/pipeline_variable.rb5
-rw-r--r--app/models/ci/processable.rb1
-rw-r--r--app/models/ci/runner.rb17
-rw-r--r--app/models/ci/stage.rb21
-rw-r--r--app/models/ci/trigger.rb6
-rw-r--r--app/models/clusters/applications/ingress.rb4
-rw-r--r--app/models/commit.rb16
-rw-r--r--app/models/commit_status.rb10
-rw-r--r--app/models/concerns/approvable.rb (renamed from app/models/concerns/approvable_base.rb)9
-rw-r--r--app/models/concerns/ci/artifactable.rb11
-rw-r--r--app/models/concerns/ci/has_deployment_name.rb15
-rw-r--r--app/models/concerns/ci/lockable.rb20
-rw-r--r--app/models/concerns/ci/metadatable.rb2
-rw-r--r--app/models/concerns/ci/partitionable.rb47
-rw-r--r--app/models/concerns/ci/track_environment_usage.rb31
-rw-r--r--app/models/concerns/counter_attribute.rb20
-rw-r--r--app/models/concerns/enums/ci/commit_status.rb6
-rw-r--r--app/models/concerns/enums/internal_id.rb3
-rw-r--r--app/models/concerns/from_set_operator.rb25
-rw-r--r--app/models/concerns/integrations/slack_mattermost_notifier.rb14
-rw-r--r--app/models/concerns/merge_request_reviewer_state.rb11
-rw-r--r--app/models/concerns/pg_full_text_searchable.rb1
-rw-r--r--app/models/concerns/project_features_compatibility.rb4
-rw-r--r--app/models/concerns/sortable.rb27
-rw-r--r--app/models/container_repository.rb9
-rw-r--r--app/models/customer_relations/contact.rb45
-rw-r--r--app/models/customer_relations/organization.rb33
-rw-r--r--app/models/deployment.rb25
-rw-r--r--app/models/environment.rb27
-rw-r--r--app/models/environment_status.rb2
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb16
-rw-r--r--app/models/group.rb85
-rw-r--r--app/models/group_group_link.rb4
-rw-r--r--app/models/hooks/web_hook_log.rb2
-rw-r--r--app/models/incident_management/timeline_event.rb2
-rw-r--r--app/models/integration.rb6
-rw-r--r--app/models/integrations/datadog.rb26
-rw-r--r--app/models/integrations/discord.rb46
-rw-r--r--app/models/integrations/hangouts_chat.rb25
-rw-r--r--app/models/integrations/harbor.rb4
-rw-r--r--app/models/integrations/shimo.rb2
-rw-r--r--app/models/internal_id.rb5
-rw-r--r--app/models/issue.rb43
-rw-r--r--app/models/jira_connect_installation.rb6
-rw-r--r--app/models/loose_foreign_keys/deleted_record.rb4
-rw-r--r--app/models/member.rb101
-rw-r--r--app/models/merge_request.rb116
-rw-r--r--app/models/merge_request/predictions.rb7
-rw-r--r--app/models/merge_request_assignee.rb5
-rw-r--r--app/models/merge_request_reviewer.rb2
-rw-r--r--app/models/ml/candidate.rb13
-rw-r--r--app/models/ml/experiment.rb26
-rw-r--r--app/models/namespace.rb22
-rw-r--r--app/models/namespace_setting.rb15
-rw-r--r--app/models/namespaces/traversal/linear.rb10
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb29
-rw-r--r--app/models/note.rb7
-rw-r--r--app/models/notification_recipient.rb10
-rw-r--r--app/models/oauth_access_token.rb9
-rw-r--r--app/models/onboarding/completion.rb70
-rw-r--r--app/models/onboarding/learn_gitlab.rb38
-rw-r--r--app/models/onboarding/progress.rb118
-rw-r--r--app/models/onboarding_progress.rb114
-rw-r--r--app/models/packages/package.rb43
-rw-r--r--app/models/packages/policies/group.rb15
-rw-r--r--app/models/packages/policies/project.rb15
-rw-r--r--app/models/packages/rpm.rb8
-rw-r--r--app/models/packages/rpm/metadatum.rb51
-rw-r--r--app/models/pages_domain.rb12
-rw-r--r--app/models/personal_access_token.rb2
-rw-r--r--app/models/pool_repository.rb4
-rw-r--r--app/models/preloaders/environments/deployment_preloader.rb10
-rw-r--r--app/models/preloaders/group_policy_preloader.rb2
-rw-r--r--app/models/preloaders/project_policy_preloader.rb23
-rw-r--r--app/models/preloaders/project_root_ancestor_preloader.rb37
-rw-r--r--app/models/preloaders/users_max_access_level_in_projects_preloader.rb2
-rw-r--r--app/models/project.rb121
-rw-r--r--app/models/project_feature.rb1
-rw-r--r--app/models/project_setting.rb11
-rw-r--r--app/models/project_statistics.rb34
-rw-r--r--app/models/projects/build_artifacts_size_refresh.rb11
-rw-r--r--app/models/projects/topic.rb8
-rw-r--r--app/models/protected_branch.rb4
-rw-r--r--app/models/repository.rb28
-rw-r--r--app/models/resource_state_event.rb7
-rw-r--r--app/models/resource_timebox_event.rb6
-rw-r--r--app/models/route.rb18
-rw-r--r--app/models/snippet.rb10
-rw-r--r--app/models/snippet_repository.rb2
-rw-r--r--app/models/system_note_metadata.rb1
-rw-r--r--app/models/todo.rb9
-rw-r--r--app/models/user.rb99
-rw-r--r--app/models/user_status.rb4
-rw-r--r--app/models/users/callout.rb14
-rw-r--r--app/models/users/credit_card_validation.rb6
-rw-r--r--app/models/users/ghost_user_migration.rb12
-rw-r--r--app/models/users/group_callout.rb8
-rw-r--r--app/models/users/namespace_callout.rb8
-rw-r--r--app/models/users/project_callout.rb4
-rw-r--r--app/models/users_star_project.rb2
-rw-r--r--app/models/wiki.rb96
-rw-r--r--app/models/work_item.rb4
-rw-r--r--app/models/work_items/widgets/description.rb8
-rw-r--r--app/policies/ci/build_policy.rb8
-rw-r--r--app/policies/ci/job_artifact_policy.rb7
-rw-r--r--app/policies/ci/runner_policy.rb52
-rw-r--r--app/policies/group_policy.rb22
-rw-r--r--app/policies/issuable_policy.rb5
-rw-r--r--app/policies/packages/package_policy.rb2
-rw-r--r--app/policies/packages/policies/group_policy.rb27
-rw-r--r--app/policies/packages/policies/project_policy.rb54
-rw-r--r--app/policies/project_policy.rb8
-rw-r--r--app/policies/protected_branch_access_policy.rb5
-rw-r--r--app/policies/protected_branch_policy.rb1
-rw-r--r--app/presenters/ci/pipeline_presenter.rb2
-rw-r--r--app/presenters/commit_status_presenter.rb7
-rw-r--r--app/presenters/deployments/deployment_presenter.rb17
-rw-r--r--app/presenters/project_presenter.rb2
-rw-r--r--app/serializers/access_token_entity_base.rb8
-rw-r--r--app/serializers/environment_serializer.rb3
-rw-r--r--app/serializers/group_access_token_entity.rb6
-rw-r--r--app/serializers/impersonation_access_token_entity.rb11
-rw-r--r--app/serializers/impersonation_access_token_serializer.rb7
-rw-r--r--app/serializers/import/provider_repo_serializer.rb2
-rw-r--r--app/serializers/member_user_entity.rb4
-rw-r--r--app/serializers/merge_request_noteable_entity.rb15
-rw-r--r--app/serializers/merge_request_user_entity.rb12
-rw-r--r--app/serializers/personal_access_token_entity.rb2
-rw-r--r--app/serializers/project_access_token_entity.rb6
-rw-r--r--app/serializers/request_aware_entity.rb2
-rw-r--r--app/services/alert_management/process_prometheus_alert_service.rb5
-rw-r--r--app/services/auth/container_registry_authentication_service.rb1
-rw-r--r--app/services/authorized_project_update/find_records_due_for_refresh_service.rb32
-rw-r--r--app/services/boards/base_item_move_service.rb22
-rw-r--r--app/services/boards/issues/move_service.rb4
-rw-r--r--app/services/bulk_imports/file_download_service.rb83
-rw-r--r--app/services/bulk_imports/relation_export_service.rb2
-rw-r--r--app/services/bulk_imports/tree_export_service.rb8
-rw-r--r--app/services/ci/after_requeue_job_service.rb36
-rw-r--r--app/services/ci/archive_trace_service.rb2
-rw-r--r--app/services/ci/build_erase_service.rb49
-rw-r--r--app/services/ci/build_report_result_service.rb3
-rw-r--r--app/services/ci/compare_reports_base_service.rb9
-rw-r--r--app/services/ci/create_downstream_pipeline_service.rb15
-rw-r--r--app/services/ci/create_pipeline_service.rb1
-rw-r--r--app/services/ci/delete_objects_service.rb4
-rw-r--r--app/services/ci/expire_pipeline_cache_service.rb2
-rw-r--r--app/services/ci/generate_coverage_reports_service.rb2
-rw-r--r--app/services/ci/job_artifacts/create_service.rb2
-rw-r--r--app/services/ci/job_artifacts/delete_service.rb32
-rw-r--r--app/services/ci/job_artifacts/track_artifact_report_service.rb23
-rw-r--r--app/services/ci/pipeline_artifacts/coverage_report_service.rb8
-rw-r--r--app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb20
-rw-r--r--app/services/ci/pipelines/add_job_service.rb2
-rw-r--r--app/services/ci/queue/pending_builds_strategy.rb6
-rw-r--r--app/services/ci/register_job_service.rb2
-rw-r--r--app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb6
-rw-r--r--app/services/ci/runners/set_runner_associated_projects_service.rb69
-rw-r--r--app/services/ci/runners/update_runner_service.rb9
-rw-r--r--app/services/ci/stuck_builds/drop_helpers.rb2
-rw-r--r--app/services/ci/test_failure_history_service.rb4
-rw-r--r--app/services/ci/unlock_artifacts_service.rb17
-rw-r--r--app/services/commits/create_service.rb2
-rw-r--r--app/services/concerns/alert_management/alert_processing.rb4
-rw-r--r--app/services/concerns/ci/downstream_pipeline_helpers.rb3
-rw-r--r--app/services/concerns/ci/job_token_scope/edit_scope_validations.rb4
-rw-r--r--app/services/concerns/projects/container_repository/gitlab/timeoutable.rb27
-rw-r--r--app/services/container_expiration_policies/cleanup_service.rb2
-rw-r--r--app/services/deployments/update_environment_service.rb14
-rw-r--r--app/services/design_management/copy_design_collection/copy_service.rb4
-rw-r--r--app/services/design_management/delete_designs_service.rb3
-rw-r--r--app/services/design_management/runs_design_actions.rb2
-rw-r--r--app/services/design_management/save_designs_service.rb6
-rw-r--r--app/services/environments/stop_service.rb15
-rw-r--r--app/services/files/multi_service.rb2
-rw-r--r--app/services/google_cloud/create_cloudsql_instance_service.rb2
-rw-r--r--app/services/google_cloud/enable_cloudsql_service.rb2
-rw-r--r--app/services/google_cloud/fetch_google_ip_list_service.rb89
-rw-r--r--app/services/groups/create_service.rb2
-rw-r--r--app/services/import/github_service.rb8
-rw-r--r--app/services/issuable_base_service.rb6
-rw-r--r--app/services/issuable_links/create_service.rb8
-rw-r--r--app/services/issues/base_service.rb3
-rw-r--r--app/services/issues/close_service.rb9
-rw-r--r--app/services/issues/export_csv_service.rb10
-rw-r--r--app/services/issues/relative_position_rebalancing_service.rb2
-rw-r--r--app/services/issues/reopen_service.rb7
-rw-r--r--app/services/labels/transfer_service.rb6
-rw-r--r--app/services/members/update_service.rb9
-rw-r--r--app/services/merge_requests/after_create_service.rb2
-rw-r--r--app/services/merge_requests/approval_service.rb41
-rw-r--r--app/services/merge_requests/base_service.rb43
-rw-r--r--app/services/merge_requests/ff_merge_service.rb30
-rw-r--r--app/services/merge_requests/handle_assignees_change_service.rb2
-rw-r--r--app/services/merge_requests/merge_service.rb23
-rw-r--r--app/services/merge_requests/mergeability/detailed_merge_status_service.rb63
-rw-r--r--app/services/merge_requests/mergeability/logger.rb103
-rw-r--r--app/services/merge_requests/mergeability/run_checks_service.rb13
-rw-r--r--app/services/merge_requests/refresh_service.rb1
-rw-r--r--app/services/merge_requests/update_assignees_service.rb2
-rw-r--r--app/services/merge_requests/update_service.rb14
-rw-r--r--app/services/milestones/transfer_service.rb5
-rw-r--r--app/services/namespaces/in_product_marketing_emails_service.rb2
-rw-r--r--app/services/notification_recipients/builder/base.rb52
-rw-r--r--app/services/onboarding/progress_service.rb33
-rw-r--r--app/services/onboarding_progress_service.rb31
-rw-r--r--app/services/packages/conan/search_service.rb2
-rw-r--r--app/services/packages/debian/generate_distribution_service.rb1
-rw-r--r--app/services/packages/debian/process_changes_service.rb16
-rw-r--r--app/services/packages/rpm/repository_metadata/base_builder.rb20
-rw-r--r--app/services/packages/rpm/repository_metadata/build_filelist_xml.rb14
-rw-r--r--app/services/packages/rpm/repository_metadata/build_other_xml.rb14
-rw-r--r--app/services/packages/rpm/repository_metadata/build_primary_xml.rb15
-rw-r--r--app/services/packages/rpm/repository_metadata/build_repomd_xml.rb59
-rw-r--r--app/services/packages/rubygems/dependency_resolver_service.rb5
-rw-r--r--app/services/post_receive_service.rb2
-rw-r--r--app/services/projects/alerting/notify_service.rb17
-rw-r--r--app/services/projects/blame_service.rb17
-rw-r--r--app/services/projects/container_repository/base_container_repository_service.rb17
-rw-r--r--app/services/projects/container_repository/cleanup_tags_base_service.rb119
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb161
-rw-r--r--app/services/projects/container_repository/gitlab/cleanup_tags_service.rb81
-rw-r--r--app/services/projects/container_repository/gitlab/delete_tags_service.rb15
-rw-r--r--app/services/projects/create_service.rb17
-rw-r--r--app/services/projects/destroy_service.rb26
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb20
-rw-r--r--app/services/projects/update_pages_service.rb11
-rw-r--r--app/services/releases/create_service.rb3
-rw-r--r--app/services/resource_events/change_labels_service.rb5
-rw-r--r--app/services/service_ping/submit_service.rb78
-rw-r--r--app/services/service_response.rb26
-rw-r--r--app/services/snippets/base_service.rb9
-rw-r--r--app/services/snippets/bulk_destroy_service.rb4
-rw-r--r--app/services/snippets/create_service.rb3
-rw-r--r--app/services/snippets/update_service.rb5
-rw-r--r--app/services/spam/spam_action_service.rb9
-rw-r--r--app/services/spam/spam_constants.rb1
-rw-r--r--app/services/spam/spam_verdict_service.rb10
-rw-r--r--app/services/system_notes/issuables_service.rb56
-rw-r--r--app/services/system_notes/time_tracking_service.rb14
-rw-r--r--app/services/topics/merge_service.rb13
-rw-r--r--app/services/users/authorized_build_service.rb2
-rw-r--r--app/services/users/destroy_service.rb51
-rw-r--r--app/services/users/email_verification/base_service.rb27
-rw-r--r--app/services/users/email_verification/generate_token_service.rb21
-rw-r--r--app/services/users/email_verification/validate_token_service.rb78
-rw-r--r--app/services/users/migrate_records_to_ghost_user_in_batches_service.rb26
-rw-r--r--app/services/users/migrate_records_to_ghost_user_service.rb111
-rw-r--r--app/uploaders/object_storage/cdn.rb46
-rw-r--r--app/uploaders/object_storage/cdn/google_cdn.rb71
-rw-r--r--app/uploaders/object_storage/cdn/google_ip_cache.rb60
-rw-r--r--app/uploaders/packages/package_file_uploader.rb4
-rw-r--r--app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json10
-rw-r--r--app/views/abuse_reports/new.html.haml6
-rw-r--r--app/views/admin/application_settings/_abuse.html.haml6
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml15
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml12
-rw-r--r--app/views/admin/application_settings/_default_branch.html.haml2
-rw-r--r--app/views/admin/application_settings/_diff_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_eks.html.haml2
-rw-r--r--app/views/admin/application_settings/_email.html.haml2
-rw-r--r--app/views/admin/application_settings/_error_tracking.html.haml2
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_floc.html.haml11
-rw-r--r--app/views/admin/application_settings/_git_lfs_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_gitaly.html.haml2
-rw-r--r--app/views/admin/application_settings/_gitpod.html.haml2
-rw-r--r--app/views/admin/application_settings/_grafana.html.haml2
-rw-r--r--app/views/admin/application_settings/_help_page.html.haml2
-rw-r--r--app/views/admin/application_settings/_import_export_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_invitation_flow_enforcement.html.haml8
-rw-r--r--app/views/admin/application_settings/_ip_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_issue_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_jira_connect_application_key.html.haml2
-rw-r--r--app/views/admin/application_settings/_kroki.html.haml2
-rw-r--r--app/views/admin/application_settings/_localization.html.haml2
-rw-r--r--app/views/admin/application_settings/_mailgun.html.haml2
-rw-r--r--app/views/admin/application_settings/_network_rate_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_note_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_outbound.html.haml4
-rw-r--r--app/views/admin/application_settings/_package_registry.html.haml2
-rw-r--r--app/views/admin/application_settings/_pages.html.haml9
-rw-r--r--app/views/admin/application_settings/_performance.html.haml2
-rw-r--r--app/views/admin/application_settings/_performance_bar.html.haml2
-rw-r--r--app/views/admin/application_settings/_pipeline_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_plantuml.html.haml2
-rw-r--r--app/views/admin/application_settings/_prometheus.html.haml6
-rw-r--r--app/views/admin/application_settings/_protected_paths.html.haml2
-rw-r--r--app/views/admin/application_settings/_realtime.html.haml2
-rw-r--r--app/views/admin/application_settings/_registry.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_static_objects.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml2
-rw-r--r--app/views/admin/application_settings/_runner_registrars_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_search_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_sentry.html.haml2
-rw-r--r--app/views/admin/application_settings/_sidekiq_job_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_signin.html.haml2
-rw-r--r--app/views/admin/application_settings/_signup.html.haml2
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml2
-rw-r--r--app/views/admin/application_settings/_sourcegraph.html.haml2
-rw-r--r--app/views/admin/application_settings/_spam.html.haml2
-rw-r--r--app/views/admin/application_settings/_terminal.html.haml2
-rw-r--r--app/views/admin/application_settings/_terms.html.haml2
-rw-r--r--app/views/admin/application_settings/_third_party_offers.html.haml2
-rw-r--r--app/views/admin/application_settings/_usage.html.haml2
-rw-r--r--app/views/admin/application_settings/_users_api_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml2
-rw-r--r--app/views/admin/application_settings/_whats_new.html.haml2
-rw-r--r--app/views/admin/application_settings/appearances/_form.html.haml2
-rw-r--r--app/views/admin/application_settings/general.html.haml4
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml2
-rw-r--r--app/views/admin/applications/_form.html.haml2
-rw-r--r--app/views/admin/background_migrations/index.html.haml2
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml2
-rw-r--r--app/views/admin/groups/_form.html.haml66
-rw-r--r--app/views/admin/hooks/_form.html.haml2
-rw-r--r--app/views/admin/identities/_form.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml229
-rw-r--r--app/views/admin/sessions/_new_base.html.haml2
-rw-r--r--app/views/admin/sessions/_signin_box.html.haml2
-rw-r--r--app/views/admin/spam_logs/_spam_log.html.haml12
-rw-r--r--app/views/admin/topics/_form.html.haml2
-rw-r--r--app/views/admin/topics/index.html.haml22
-rw-r--r--app/views/admin/users/_form.html.haml2
-rw-r--r--app/views/award_emoji/_awards_block.html.haml2
-rw-r--r--app/views/clusters/clusters/_gitlab_integration_form.html.haml2
-rw-r--r--app/views/dashboard/_activities.html.haml2
-rw-r--r--app/views/dashboard/milestones/index.html.haml6
-rw-r--r--app/views/dashboard/projects/_blank_state_welcome.html.haml11
-rw-r--r--app/views/dashboard/todos/_todo.html.haml8
-rw-r--r--app/views/dashboard/todos/index.html.haml2
-rw-r--r--app/views/devise/sessions/_new_base.html.haml4
-rw-r--r--app/views/devise/sessions/new.html.haml7
-rw-r--r--app/views/devise/sessions/successful_verification.haml2
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml11
-rw-r--r--app/views/devise/shared/_signin_box.html.haml2
-rw-r--r--app/views/devise/shared/_signup_box.html.haml8
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml2
-rw-r--r--app/views/devise/shared/_tab_single.html.haml2
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml4
-rw-r--r--app/views/devise/shared/_terms_of_service_notice.html.haml18
-rw-r--r--app/views/groups/_activities.html.haml2
-rw-r--r--app/views/groups/_home_panel.html.haml8
-rw-r--r--app/views/groups/_new_group_fields.html.haml20
-rw-r--r--app/views/groups/crm/organizations/index.html.haml2
-rw-r--r--app/views/groups/harbor/repositories/index.html.haml5
-rw-r--r--app/views/groups/milestones/_form.html.haml2
-rw-r--r--app/views/groups/observability/index.html.haml2
-rw-r--r--app/views/groups/runners/edit.html.haml15
-rw-r--r--app/views/groups/runners/index.html.haml2
-rw-r--r--app/views/groups/settings/_advanced.html.haml2
-rw-r--r--app/views/groups/settings/_general.html.haml2
-rw-r--r--app/views/groups/settings/_permissions.html.haml2
-rw-r--r--app/views/groups/settings/ci_cd/_auto_devops_form.html.haml2
-rw-r--r--app/views/groups/settings/packages_and_registries/show.html.haml4
-rw-r--r--app/views/groups/settings/repository/_default_branch.html.haml2
-rw-r--r--app/views/groups/settings/repository/show.html.haml10
-rw-r--r--app/views/groups/show.html.haml53
-rw-r--r--app/views/help/drawers.html.haml2
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml2
-rw-r--r--app/views/layouts/_google_tag_manager_head.html.haml15
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/devise.html.haml2
-rw-r--r--app/views/layouts/fullscreen.html.haml18
-rw-r--r--app/views/layouts/group.html.haml5
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml28
-rw-r--r--app/views/layouts/header/_storage_enforcement_banner.html.haml15
-rw-r--r--app/views/layouts/nav/_top_nav.html.haml7
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml2
-rw-r--r--app/views/layouts/profile.html.haml2
-rw-r--r--app/views/layouts/project.html.haml4
-rw-r--r--app/views/notify/_failed_builds.html.haml2
-rw-r--r--app/views/notify/_successful_pipeline.html.haml15
-rw-r--r--app/views/notify/approved_merge_request_email.html.haml22
-rw-r--r--app/views/notify/autodevops_disabled_email.text.erb2
-rw-r--r--app/views/notify/change_in_merge_request_draft_status_email.html.haml8
-rw-r--r--app/views/notify/change_in_merge_request_draft_status_email.text.erb6
-rw-r--r--app/views/notify/import_issues_csv_email.html.haml14
-rw-r--r--app/views/notify/new_gpg_key_email.html.haml11
-rw-r--r--app/views/notify/new_mention_in_issue_email.html.haml2
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.html.haml2
-rw-r--r--app/views/notify/new_ssh_key_email.html.haml14
-rw-r--r--app/views/notify/new_user_email.html.haml16
-rw-r--r--app/views/notify/pipeline_failed_email.text.erb2
-rw-r--r--app/views/notify/pipeline_fixed_email.html.haml2
-rw-r--r--app/views/notify/push_to_merge_request_email.html.haml13
-rw-r--r--app/views/notify/remote_mirror_update_failed_email.html.haml18
-rw-r--r--app/views/notify/removed_milestone_issue_email.html.haml2
-rw-r--r--app/views/notify/removed_milestone_merge_request_email.html.haml2
-rw-r--r--app/views/notify/repository_push_email.html.haml29
-rw-r--r--app/views/notify/resolved_all_discussions_email.html.haml3
-rw-r--r--app/views/notify/send_admin_notification.html.haml4
-rw-r--r--app/views/notify/unapproved_merge_request_email.html.haml22
-rw-r--r--app/views/profiles/_email_settings.html.haml4
-rw-r--r--app/views/profiles/active_sessions/index.html.haml7
-rw-r--r--app/views/profiles/emails/index.html.haml28
-rw-r--r--app/views/profiles/gpg_keys/_form.html.haml2
-rw-r--r--app/views/profiles/keys/_form.html.haml10
-rw-r--r--app/views/profiles/keys/_key.html.haml2
-rw-r--r--app/views/profiles/keys/_key_details.html.haml2
-rw-r--r--app/views/profiles/notifications/_email_settings.html.haml2
-rw-r--r--app/views/profiles/notifications/show.html.haml2
-rw-r--r--app/views/profiles/passwords/edit.html.haml2
-rw-r--r--app/views/profiles/passwords/new.html.haml2
-rw-r--r--app/views/profiles/preferences/show.html.haml5
-rw-r--r--app/views/profiles/show.html.haml41
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml2
-rw-r--r--app/views/projects/_activity.html.haml2
-rw-r--r--app/views/projects/_commit_button.html.haml5
-rw-r--r--app/views/projects/_errors.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml6
-rw-r--r--app/views/projects/_service_desk_settings.html.haml3
-rw-r--r--app/views/projects/_stat_anchor_list.html.haml2
-rw-r--r--app/views/projects/activity.html.haml2
-rw-r--r--app/views/projects/blame/show.html.haml16
-rw-r--r--app/views/projects/blob/_header.html.haml5
-rw-r--r--app/views/projects/blob/edit.html.haml1
-rw-r--r--app/views/projects/blob/new.html.haml1
-rw-r--r--app/views/projects/blob/show.html.haml1
-rw-r--r--app/views/projects/branch_rules/_show.html.haml2
-rw-r--r--app/views/projects/branches/new.html.haml7
-rw-r--r--app/views/projects/buttons/_clone.html.haml4
-rw-r--r--app/views/projects/ci/builds/_build.html.haml2
-rw-r--r--app/views/projects/ci/pipeline_editor/show.html.haml1
-rw-r--r--app/views/projects/commits/show.html.haml2
-rw-r--r--app/views/projects/default_branch/_show.html.haml2
-rw-r--r--app/views/projects/edit.html.haml26
-rw-r--r--app/views/projects/forks/new.html.haml2
-rw-r--r--app/views/projects/google_cloud/databases/cloudsql_form.html.haml9
-rw-r--r--app/views/projects/google_cloud/gcp_regions/index.html.haml2
-rw-r--r--app/views/projects/harbor/repositories/index.html.haml5
-rw-r--r--app/views/projects/issues/_discussion.html.haml3
-rw-r--r--app/views/projects/issues/_related_branches.html.haml2
-rw-r--r--app/views/projects/issues/_work_item_links.html.haml2
-rw-r--r--app/views/projects/labels/index.html.haml1
-rw-r--r--app/views/projects/merge_requests/_awards_block.html.haml2
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml18
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml8
-rw-r--r--app/views/projects/merge_requests/show.html.haml22
-rw-r--r--app/views/projects/milestones/_form.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml40
-rw-r--r--app/views/projects/mirrors/_mirror_repos_list.html.haml47
-rw-r--r--app/views/projects/pages/_header.html.haml2
-rw-r--r--app/views/projects/pages/new.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml4
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/project_members/index.html.haml1
-rw-r--r--app/views/projects/project_templates/_template.html.haml2
-rw-r--r--app/views/projects/protected_branches/_create_protected_branch.html.haml12
-rw-r--r--app/views/projects/protected_branches/shared/_branches_list.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_protected_branch.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml3
-rw-r--r--app/views/projects/settings/merge_requests/show.html.haml18
-rw-r--r--app/views/projects/settings/operations/show.html.haml13
-rw-r--r--app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml4
-rw-r--r--app/views/projects/settings/packages_and_registries/show.html.haml4
-rw-r--r--app/views/projects/show.html.haml1
-rw-r--r--app/views/projects/tags/index.html.haml2
-rw-r--r--app/views/projects/tags/new.html.haml22
-rw-r--r--app/views/projects/triggers/_form.html.haml2
-rw-r--r--app/views/projects/usage_quotas/index.html.haml2
-rw-r--r--app/views/search/_results_status.html.haml2
-rw-r--r--app/views/shared/_email_with_badge.html.haml5
-rw-r--r--app/views/shared/_file_highlight.html.haml3
-rw-r--r--app/views/shared/_integration_settings.html.haml2
-rw-r--r--app/views/shared/_md_preview.html.haml6
-rw-r--r--app/views/shared/access_tokens/_created_container.html.haml2
-rw-r--r--app/views/shared/access_tokens/_form.html.haml2
-rw-r--r--app/views/shared/boards/_show.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_index.html.haml7
-rw-r--r--app/views/shared/doorkeeper/applications/_form.html.haml2
-rw-r--r--app/views/shared/doorkeeper/applications/_show.html.haml9
-rw-r--r--app/views/shared/groups/_group.html.haml2
-rw-r--r--app/views/shared/groups/_visibility_level.html.haml3
-rw-r--r--app/views/shared/hook_logs/_recent_deliveries_table.html.haml2
-rw-r--r--app/views/shared/issuable/_feed_buttons.html.haml2
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml11
-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/_branch_chooser.html.haml12
-rw-r--r--app/views/shared/issuable/form/_merge_params.html.haml8
-rw-r--r--app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml2
-rw-r--r--app/views/shared/issue_type/_emoji_block.html.haml3
-rw-r--r--app/views/shared/labels/_form.html.haml2
-rw-r--r--app/views/shared/milestones/_milestone.html.haml3
-rw-r--r--app/views/shared/notes/_hints.html.haml2
-rw-r--r--app/views/shared/projects/_search_form.html.haml2
-rw-r--r--app/views/shared/runners/_form.html.haml2
-rw-r--r--app/views/shared/web_hooks/_form.html.haml2
-rw-r--r--app/views/shared/web_hooks/_index.html.haml2
-rw-r--r--app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml13
-rw-r--r--app/views/shared/wikis/_form.html.haml2
-rw-r--r--app/views/shared/wikis/_wiki_content.html.haml2
-rw-r--r--app/views/shared/wikis/git_error.html.haml2
-rw-r--r--app/views/shared/wikis/show.html.haml2
-rw-r--r--app/views/users/show.html.haml2
-rw-r--r--app/workers/all_queues.yml81
-rw-r--r--app/workers/analytics/usage_trends/counter_job_worker.rb25
-rw-r--r--app/workers/ci/build_finished_worker.rb4
-rw-r--r--app/workers/ci/job_artifacts/track_artifact_report_worker.rb23
-rw-r--r--app/workers/ci/pipeline_artifacts/coverage_report_worker.rb2
-rw-r--r--app/workers/cleanup_container_repository_worker.rb2
-rw-r--r--app/workers/flush_counter_increments_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/advance_stage_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/import_protected_branch_worker.rb23
-rw-r--r--app/workers/gitlab/github_import/import_release_attachments_worker.rb21
-rw-r--r--app/workers/gitlab/github_import/stage/import_attachments_worker.rb58
-rw-r--r--app/workers/gitlab/github_import/stage/import_notes_worker.rb6
-rw-r--r--app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb45
-rw-r--r--app/workers/gitlab/jira_import/import_issue_worker.rb3
-rw-r--r--app/workers/gitlab_service_ping_worker.rb15
-rw-r--r--app/workers/google_cloud/create_cloudsql_instance_worker.rb23
-rw-r--r--app/workers/google_cloud/fetch_google_ip_list_worker.rb17
-rw-r--r--app/workers/groups/update_two_factor_requirement_for_members_worker.rb22
-rw-r--r--app/workers/issues/close_worker.rb50
-rw-r--r--app/workers/namespaces/onboarding_issue_created_worker.rb2
-rw-r--r--app/workers/namespaces/onboarding_pipeline_created_worker.rb2
-rw-r--r--app/workers/namespaces/onboarding_progress_worker.rb2
-rw-r--r--app/workers/namespaces/onboarding_user_added_worker.rb2
-rw-r--r--app/workers/namespaces/process_sync_events_worker.rb2
-rw-r--r--app/workers/object_storage/migrate_uploads_worker.rb57
-rw-r--r--app/workers/process_commit_worker.rb39
-rw-r--r--app/workers/projects/inactive_projects_deletion_cron_worker.rb2
-rw-r--r--app/workers/projects/process_sync_events_worker.rb2
-rw-r--r--app/workers/ssh_keys/expired_notification_worker.rb27
-rw-r--r--app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb22
1512 files changed, 20454 insertions, 9163 deletions
diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
index 59f0e0dd17d..461b2dad479 100644
--- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
+++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
@@ -140,6 +140,7 @@ export default {
<template #cell(action)="{ item: { revokePath } }">
<gl-button
+ v-if="revokePath"
category="tertiary"
:aria-label="$options.i18n.revokeButton"
:data-confirm="modalMessage"
diff --git a/app/assets/javascripts/access_tokens/components/constants.js b/app/assets/javascripts/access_tokens/components/constants.js
index 84e50bc099f..9cd7cb5bb3a 100644
--- a/app/assets/javascripts/access_tokens/components/constants.js
+++ b/app/assets/javascripts/access_tokens/components/constants.js
@@ -12,8 +12,6 @@ export const FIELDS = [
key: 'name',
label: __('Token name'),
sortable: true,
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
},
{
formatter(scopes) {
@@ -22,40 +20,30 @@ export const FIELDS = [
key: 'scopes',
label: __('Scopes'),
sortable: true,
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
},
{
key: 'createdAt',
label: s__('AccessTokens|Created'),
sortable: true,
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
},
{
key: 'lastUsedAt',
label: __('Last Used'),
sortable: true,
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
},
{
key: 'expiresAt',
label: __('Expires'),
sortable: true,
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
},
{
key: 'role',
label: __('Role'),
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
sortable: true,
},
{
key: 'action',
label: __('Action'),
- thClass: `gl-text-black-normal`,
+ tdClass: 'gl-py-3!',
},
];
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 5516fd0daf6..38501d63d3a 100644
--- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -16,6 +16,16 @@ export default {
import('ee_component/access_tokens/components/max_expiration_date_message.vue'),
},
props: {
+ defaultDateOffset: {
+ type: Number,
+ required: false,
+ default: 30,
+ },
+ description: {
+ type: String,
+ required: false,
+ default: null,
+ },
inputAttrs: {
type: Object,
required: false,
@@ -33,9 +43,15 @@ export default {
},
},
computed: {
- in30Days() {
- const today = new Date();
- return getDateInFuture(today, 30);
+ defaultDate() {
+ const defaultDate = getDateInFuture(new Date(), this.defaultDateOffset);
+ // The maximum date can be set by admins. If the maximum date is sooner
+ // than the default expiration date we use the maximum date as default
+ // expiration date.
+ if (this.maxDate && this.maxDate < defaultDate) {
+ return this.maxDate;
+ }
+ return defaultDate;
},
},
};
@@ -47,7 +63,7 @@ export default {
:target="null"
:min-date="minDate"
:max-date="maxDate"
- :default-date="in30Days"
+ :default-date="defaultDate"
show-clear-button
:input-name="inputAttrs.name"
:input-id="inputAttrs.id"
@@ -55,7 +71,10 @@ export default {
data-qa-selector="expiry_date_field"
/>
<template #description>
- <max-expiration-date-message :max-date="maxDate" />
+ <template v-if="description">
+ {{ description }}
+ </template>
+ <max-expiration-date-message v-else :max-date="maxDate" />
</template>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
index e111ae91e5c..6b52bd84656 100644
--- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
+++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
@@ -42,7 +42,6 @@ export default {
formInputGroupProps() {
return {
id: this.$options.tokenInputId,
- class: 'qa-created-access-token',
'data-qa-selector': 'created_access_token_field',
name: this.$options.tokenInputId,
};
@@ -82,7 +81,14 @@ export default {
this.infoAlert = createAlert({ message: this.alertInfoMessage, variant: VARIANT_INFO });
- this.form.reset();
+ // Selectively reset all input fields except for the date picker and submit.
+ // The form token creation is not controlled by Vue.
+ this.form.querySelectorAll('input[type=text]:not([id$=expires_at])').forEach((el) => {
+ el.value = '';
+ });
+ this.form.querySelectorAll('input[type=checkbox]').forEach((el) => {
+ el.checked = false;
+ });
},
},
};
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 9801aa08e28..f0c1b415157 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -61,7 +61,7 @@ export const initExpiresAtField = () => {
}
const { expiresAt: inputAttrs } = parseRailsFormFields(el);
- const { minDate, maxDate } = el.dataset;
+ const { minDate, maxDate, defaultDateOffset, description } = el.dataset;
return new Vue({
el,
@@ -71,6 +71,8 @@ export const initExpiresAtField = () => {
inputAttrs,
minDate: minDate ? new Date(minDate) : undefined,
maxDate: maxDate ? new Date(maxDate) : undefined,
+ defaultDateOffset: defaultDateOffset ? Number(defaultDateOffset) : undefined,
+ description,
},
});
},
diff --git a/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue
new file mode 100644
index 00000000000..2f74b44625f
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ message: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ fieldHelpText: s__(
+ 'AdminSettings|If no unit is written, it defaults to seconds. For example, these are all equivalent: %{oneDayInSeconds}, %{oneDayInHoursHumanReadable}, or %{oneDayHumanReadable}. Minimum value is two hours. %{linkStart}Learn more.%{linkEnd}',
+ ),
+ },
+ computed: {
+ helpUrl() {
+ return helpPagePath('ci/runners/configure_runners', {
+ anchor: 'authentication-token-security',
+ });
+ },
+ },
+};
+</script>
+<template>
+ <p>
+ {{ message }}
+ <gl-sprintf :message="$options.i18n.fieldHelpText">
+ <template #oneDayInSeconds>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
+ <code>86400</code>
+ </template>
+ <template #oneDayInHoursHumanReadable>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
+ <code>24 hours</code>
+ </template>
+ <template #oneDayHumanReadable>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
+ <code>1 day</code>
+ </template>
+ <template #link>
+ <gl-link :href="helpUrl" target="_blank">{{ __('Learn more.') }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+</template>
diff --git a/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue
new file mode 100644
index 00000000000..371a26d2664
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue
@@ -0,0 +1,123 @@
+<script>
+import { GlFormGroup } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ChronicDurationInput from '~/vue_shared/components/chronic_duration_input.vue';
+import ExpirationIntervalDescription from './expiration_interval_description.vue';
+
+export default {
+ components: {
+ ChronicDurationInput,
+ ExpirationIntervalDescription,
+ GlFormGroup,
+ },
+ props: {
+ instanceRunnerExpirationInterval: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ groupRunnerExpirationInterval: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ projectRunnerExpirationInterval: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ perInput: {
+ instance: {
+ value: this.instanceRunnerExpirationInterval,
+ valid: null,
+ feedback: '',
+ },
+ group: {
+ value: this.groupRunnerExpirationInterval,
+ valid: null,
+ feedback: '',
+ },
+ project: {
+ value: this.projectRunnerExpirationInterval,
+ valid: null,
+ feedback: '',
+ },
+ },
+ };
+ },
+ methods: {
+ updateValidity(obj, event) {
+ /* eslint-disable no-param-reassign */
+ obj.valid = event.valid;
+ obj.feedback = event.feedback;
+ /* eslint-enable no-param-reassign */
+ },
+ },
+ i18n: {
+ instanceRunnerTitle: s__('AdminSettings|Instance runners expiration'),
+ instanceRunnerDescription: s__(
+ 'AdminSettings|Set the expiration time of authentication tokens of newly registered instance runners. Authentication tokens are automatically reset at these intervals.',
+ ),
+ groupRunnerTitle: s__('AdminSettings|Group runners expiration'),
+ groupRunnerDescription: s__(
+ 'AdminSettings|Set the expiration time of authentication tokens of newly registered group runners.',
+ ),
+ projectRunnerTitle: s__('AdminSettings|Project runners expiration'),
+ projectRunnerDescription: s__(
+ 'AdminSettings|Set the expiration time of authentication tokens of newly registered project runners.',
+ ),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-form-group
+ :label="$options.i18n.instanceRunnerTitle"
+ :invalid-feedback="perInput.instance.feedback"
+ :state="perInput.instance.valid"
+ >
+ <template #description>
+ <expiration-interval-description :message="$options.i18n.instanceRunnerDescription" />
+ </template>
+ <chronic-duration-input
+ v-model="perInput.instance.value"
+ name="application_setting[runner_token_expiration_interval]"
+ :state="perInput.instance.valid"
+ @valid="updateValidity(perInput.instance, $event)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.groupRunnerTitle"
+ :invalid-feedback="perInput.group.feedback"
+ :state="perInput.group.valid"
+ >
+ <template #description>
+ <expiration-interval-description :message="$options.i18n.groupRunnerDescription" />
+ </template>
+ <chronic-duration-input
+ v-model="perInput.group.value"
+ name="application_setting[group_runner_token_expiration_interval]"
+ :state="perInput.group.valid"
+ @valid="updateValidity(perInput.group, $event)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.projectRunnerTitle"
+ :invalid-feedback="perInput.project.feedback"
+ :state="perInput.project.valid"
+ >
+ <template #description>
+ <expiration-interval-description :message="$options.i18n.projectRunnerDescription" />
+ </template>
+ <chronic-duration-input
+ v-model="perInput.project.value"
+ name="application_setting[project_runner_token_expiration_interval]"
+ :state="perInput.project.valid"
+ @valid="updateValidity(perInput.project, $event)"
+ />
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js b/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js
new file mode 100644
index 00000000000..79d7ff0451a
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import { parseInterval } from '~/runner/utils';
+import ExpirationIntervals from './components/expiration_intervals.vue';
+
+const initRunnerTokenExpirationIntervals = (selector = '#js-runner-token-expiration-intervals') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ instanceRunnerTokenExpirationInterval,
+ groupRunnerTokenExpirationInterval,
+ projectRunnerTokenExpirationInterval,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(ExpirationIntervals, {
+ props: {
+ instanceRunnerExpirationInterval: parseInterval(instanceRunnerTokenExpirationInterval),
+ groupRunnerExpirationInterval: parseInterval(groupRunnerTokenExpirationInterval),
+ projectRunnerExpirationInterval: parseInterval(projectRunnerTokenExpirationInterval),
+ },
+ });
+ },
+ });
+};
+
+export default initRunnerTokenExpirationIntervals;
diff --git a/app/assets/javascripts/admin/topics/components/merge_topics.vue b/app/assets/javascripts/admin/topics/components/merge_topics.vue
new file mode 100644
index 00000000000..921b762bbef
--- /dev/null
+++ b/app/assets/javascripts/admin/topics/components/merge_topics.vue
@@ -0,0 +1,141 @@
+<script>
+import { GlAlert, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import csrf from '~/lib/utils/csrf';
+import TopicSelect from './topic_select.vue';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlModal,
+ GlSprintf,
+ TopicSelect,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: ['path'],
+ data() {
+ return {
+ sourceTopic: {},
+ targetTopic: {},
+ };
+ },
+ computed: {
+ sourceTopicId() {
+ return getIdFromGraphQLId(this.sourceTopic?.id);
+ },
+ targetTopicId() {
+ return getIdFromGraphQLId(this.targetTopic?.id);
+ },
+ validSelectedTopics() {
+ return (
+ Object.keys(this.sourceTopic).length &&
+ Object.keys(this.targetTopic).length &&
+ this.sourceTopic !== this.targetTopic
+ );
+ },
+ actionPrimary() {
+ return {
+ text: __('Merge'),
+ attributes: {
+ variant: 'danger',
+ disabled: !this.validSelectedTopics,
+ },
+ };
+ },
+ },
+ methods: {
+ selectSourceTopic(topic) {
+ this.sourceTopic = topic;
+ },
+ selectTargetTopic(topic) {
+ this.targetTopic = topic;
+ },
+ mergeTopics() {
+ this.$refs.mergeForm.submit();
+ },
+ },
+ i18n: {
+ title: s__('MergeTopics|Merge topics'),
+ body: s__(
+ 'MergeTopics|Move all assigned projects from the source topic to the target topic and remove the source topic.',
+ ),
+ sourceTopic: s__('MergeTopics|Source topic'),
+ targetTopic: s__('MergeTopics|Target topic'),
+ warningTitle: s__('MergeTopics|Merging topics will cause the following:'),
+ warningBody: s__('MergeTopics|This action cannot be undone.'),
+ warningRemoveTopic: s__('MergeTopics|%{sourceTopic} will be removed'),
+ warningMoveProjects: s__('MergeTopics|All assigned projects will be moved to %{targetTopic}'),
+ },
+ modal: {
+ id: 'merge-topics',
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
+ csrf,
+};
+</script>
+<template>
+ <div class="gl-mr-3">
+ <gl-button v-gl-modal="$options.modal.id" category="secondary">{{
+ $options.i18n.title
+ }}</gl-button>
+ <gl-modal
+ :title="$options.i18n.title"
+ :action-primary="actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
+ :modal-id="$options.modal.id"
+ size="sm"
+ @primary="mergeTopics"
+ >
+ <p>{{ $options.i18n.body }}</p>
+ <topic-select
+ :selected-topic="sourceTopic"
+ :label-text="$options.i18n.sourceTopic"
+ @click="selectSourceTopic"
+ />
+ <topic-select
+ :selected-topic="targetTopic"
+ :label-text="$options.i18n.targetTopic"
+ @click="selectTargetTopic"
+ />
+ <gl-alert
+ v-if="validSelectedTopics"
+ :title="$options.i18n.warningTitle"
+ :dismissible="false"
+ variant="danger"
+ >
+ <ul>
+ <li>
+ <gl-sprintf :message="$options.i18n.warningRemoveTopic">
+ <template #sourceTopic>
+ <strong>{{ sourceTopic.name }}</strong>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.warningMoveProjects">
+ <template #targetTopic>
+ <strong>{{ targetTopic.name }}</strong>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ {{ $options.i18n.warningBody }}
+ </gl-alert>
+ <form ref="mergeForm" method="post" :action="path">
+ <input type="hidden" name="_method" value="post" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ <input type="hidden" name="source_topic_id" :value="sourceTopicId" />
+ <input type="hidden" name="target_topic_id" :value="targetTopicId" />
+ </form>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/topics/components/topic_select.vue b/app/assets/javascripts/admin/topics/components/topic_select.vue
new file mode 100644
index 00000000000..8bf5be1afd1
--- /dev/null
+++ b/app/assets/javascripts/admin/topics/components/topic_select.vue
@@ -0,0 +1,106 @@
+<script>
+import {
+ GlAvatarLabeled,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
+import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
+
+export default {
+ components: {
+ GlAvatarLabeled,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ },
+ props: {
+ selectedTopic: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ labelText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ apollo: {
+ topics: {
+ query: searchProjectTopics,
+ variables() {
+ return {
+ search: this.search,
+ };
+ },
+ update(data) {
+ return data.topics?.nodes || [];
+ },
+ debounce: 250,
+ },
+ },
+ data() {
+ return {
+ topics: [],
+ search: '',
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.topics.loading;
+ },
+ isResultEmpty() {
+ return this.topics.length === 0;
+ },
+ dropdownText() {
+ if (Object.keys(this.selectedTopic).length) {
+ return this.selectedTopic.name;
+ }
+
+ return this.$options.i18n.dropdownText;
+ },
+ },
+ methods: {
+ selectTopic(topic) {
+ this.$emit('click', topic);
+ },
+ },
+ i18n: {
+ dropdownText: s__('TopicSelect|Select a topic'),
+ searchPlaceholder: s__('TopicSelect|Search topics'),
+ emptySearchResult: s__('TopicSelect|No matching results'),
+ },
+ AVATAR_SHAPE_OPTION_RECT,
+};
+</script>
+
+<template>
+ <div>
+ <label v-if="labelText">{{ labelText }}</label>
+ <gl-dropdown block :text="dropdownText">
+ <gl-search-box-by-type
+ v-model="search"
+ :is-loading="loading"
+ :placeholder="$options.i18n.searchPlaceholder"
+ />
+ <gl-dropdown-item v-for="topic in topics" :key="topic.id" @click="selectTopic(topic)">
+ <gl-avatar-labeled
+ :label="topic.title"
+ :sub-label="topic.name"
+ :src="topic.avatarUrl"
+ :entity-name="topic.name"
+ :size="32"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ />
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="isResultEmpty && !loading">
+ <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
+ </gl-dropdown-text>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js
index 09e9b20f220..d81690e8f4c 100644
--- a/app/assets/javascripts/admin/topics/index.js
+++ b/app/assets/javascripts/admin/topics/index.js
@@ -1,7 +1,20 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import showToast from '~/vue_shared/plugins/global_toast';
import RemoveAvatar from './components/remove_avatar.vue';
+import MergeTopics from './components/merge_topics.vue';
-export default () => {
+const toasts = document.querySelectorAll('.js-toast-message');
+toasts.forEach((toast) => showToast(toast.dataset.message));
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const initRemoveAvatar = () => {
const el = document.querySelector('.js-remove-topic-avatar');
if (!el) {
@@ -21,3 +34,20 @@ export default () => {
},
});
};
+
+export const initMergeTopics = () => {
+ const el = document.querySelector('.js-merge-topics');
+
+ if (!el) return false;
+
+ const { path } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: { path },
+ render(createElement) {
+ return createElement(MergeTopics);
+ },
+ });
+};
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue
index 696e7f359d1..388d925196b 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue
@@ -109,7 +109,7 @@ export default {
v-for="template in templates"
:key="template.key"
data-qa-selector="incident_templates_item"
- :is-check-item="true"
+ is-check-item
:is-checked="isTemplateSelected(template.key)"
@click="selectIssueTemplate(template.key)"
>
diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue
index 7df66d1b2be..92ccac59057 100644
--- a/app/assets/javascripts/analytics/shared/components/daterange.vue
+++ b/app/assets/javascripts/analytics/shared/components/daterange.vue
@@ -1,13 +1,10 @@
<script>
-import { GlDaterangePicker, GlSprintf } from '@gitlab/ui';
-import { getDayDifference } from '~/lib/utils/datetime_utility';
-import { __, sprintf } from '~/locale';
-import { OFFSET_DATE_BY_ONE } from '../constants';
+import { GlDaterangePicker } from '@gitlab/ui';
+import { n__, __, sprintf } from '~/locale';
export default {
components: {
GlDaterangePicker,
- GlSprintf,
},
props: {
show: {
@@ -69,9 +66,10 @@ export default {
this.$emit('change', { startDate, endDate });
},
},
- numberOfDays() {
- const dayDifference = getDayDifference(this.startDate, this.endDate);
- return this.includeSelectedDate ? dayDifference + OFFSET_DATE_BY_ONE : dayDifference;
+ },
+ methods: {
+ numberOfDays(daysSelected) {
+ return n__('1 day selected', '%d days selected', daysSelected);
},
},
};
@@ -83,7 +81,7 @@ export default {
>
<gl-daterange-picker
v-model="dateRange"
- class="d-flex flex-column flex-lg-row"
+ class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row"
:default-start-date="startDate"
:default-end-date="endDate"
:default-min-date="minDate"
@@ -93,12 +91,12 @@ export default {
:tooltip="maxDateRangeTooltip"
theme="animate-picker"
start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0"
- end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center gl-mb-2 gl-lg-mb-0"
+ end-picker-class="js-daterange-picker-to gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-mb-2 gl-lg-mb-0"
label-class="gl-mb-2 gl-lg-mb-0"
>
- <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)">
- <template #numberOfDays>{{ numberOfDays }}</template>
- </gl-sprintf>
+ <template #default="{ daysSelected }">
+ {{ numberOfDays(daysSelected) }}
+ </template>
</gl-daterange-picker>
</div>
</template>
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index e1bc59b36ef..c62736d55a8 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -1,8 +1,7 @@
-import { masks } from 'dateformat';
+import { masks } from '~/lib/dateformat';
import { s__ } from '~/locale';
export const DATE_RANGE_LIMIT = 180;
-export const OFFSET_DATE_BY_ONE = 1;
export const PROJECTS_PER_PAGE = 50;
const { isoDate, mediumDate } = masks;
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index 1887f2affc3..bc52e38fc81 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,5 +1,5 @@
-import dateFormat from 'dateformat';
import { hideFlash } from '~/flash';
+import dateFormat from '~/lib/dateformat';
import { slugify } from '~/lib/utils/text_utility';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from './constants';
diff --git a/app/assets/javascripts/analytics/usage_trends/utils.js b/app/assets/javascripts/analytics/usage_trends/utils.js
index 91907877ed6..9474d264363 100644
--- a/app/assets/javascripts/analytics/usage_trends/utils.js
+++ b/app/assets/javascripts/analytics/usage_trends/utils.js
@@ -1,5 +1,5 @@
-import { masks } from 'dateformat';
import { get } from 'lodash';
+import { masks } from '~/lib/dateformat';
import { formatDate } from '~/lib/utils/datetime_utility';
const { isoDate } = masks;
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 0c870a89760..b02dd9321b3 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -47,6 +47,7 @@ const Api = {
projectSharePath: '/api/:version/projects/:id/share',
projectMilestonesPath: '/api/:version/projects/:id/milestones',
projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid',
+ projectCreateIssuePath: '/api/:version/projects/:id/issues',
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/api/:version/groups/:namespace_path/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
diff --git a/app/assets/javascripts/api/harbor_registry.js b/app/assets/javascripts/api/harbor_registry.js
new file mode 100644
index 00000000000..eb241342567
--- /dev/null
+++ b/app/assets/javascripts/api/harbor_registry.js
@@ -0,0 +1,49 @@
+import axios from '~/lib/utils/axios_utils';
+import { buildApiUrl } from '~/api/api_utils';
+
+// the :request_path is loading API-like resources, not part of our REST API.
+// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82784#note_1077703806
+const HARBOR_REPOSITORIES_PATH = '/:request_path.json';
+const HARBOR_ARTIFACTS_PATH = '/:request_path/:repo_name/artifacts.json';
+const HARBOR_TAGS_PATH = '/:request_path/:repo_name/artifacts/:digest/tags.json';
+
+export function getHarborRepositoriesList({ requestPath, limit, page, sort, search = '' }) {
+ const url = buildApiUrl(HARBOR_REPOSITORIES_PATH).replace('/:request_path', requestPath);
+
+ return axios.get(url, {
+ params: {
+ limit,
+ page,
+ search,
+ sort,
+ },
+ });
+}
+
+export function getHarborArtifacts({ requestPath, repoName, limit, page, sort, search = '' }) {
+ const url = buildApiUrl(HARBOR_ARTIFACTS_PATH)
+ .replace('/:request_path', requestPath)
+ .replace(':repo_name', repoName);
+
+ return axios.get(url, {
+ params: {
+ limit,
+ page,
+ search,
+ sort,
+ },
+ });
+}
+
+export function getHarborTags({ requestPath, repoName, digest, page }) {
+ const url = buildApiUrl(HARBOR_TAGS_PATH)
+ .replace('/:request_path', requestPath)
+ .replace(':repo_name', repoName)
+ .replace(':digest', digest);
+
+ return axios.get(url, {
+ params: {
+ page,
+ },
+ });
+}
diff --git a/app/assets/javascripts/api/integrations_api.js b/app/assets/javascripts/api/integrations_api.js
deleted file mode 100644
index 692aae21a4f..00000000000
--- a/app/assets/javascripts/api/integrations_api.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import axios from '../lib/utils/axios_utils';
-import { buildApiUrl } from './api_utils';
-
-const JIRA_CONNECT_SUBSCRIPTIONS_PATH = '/api/:version/integrations/jira_connect/subscriptions';
-
-export function addJiraConnectSubscription(namespacePath, { jwt, accessToken }) {
- const url = buildApiUrl(JIRA_CONNECT_SUBSCRIPTIONS_PATH);
-
- return axios.post(
- url,
- {
- jwt,
- namespace_path: namespacePath,
- },
- {
- headers: {
- Authorization: `Bearer ${accessToken}`, // eslint-disable-line @gitlab/require-i18n-strings
- },
- },
- );
-}
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index c362253f52e..c743b18d572 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -12,7 +12,6 @@ const USER_PROJECTS_PATH = '/api/:version/users/:id/projects';
const USER_POST_STATUS_PATH = '/api/:version/user/status';
const USER_FOLLOW_PATH = '/api/:version/users/:id/follow';
const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow';
-const CURRENT_USER_PATH = '/api/:version/user';
export function getUsers(query, options) {
const url = buildApiUrl(USERS_PATH);
@@ -82,8 +81,3 @@ export function unfollowUser(userId) {
const url = buildApiUrl(USER_UNFOLLOW_PATH).replace(':id', encodeURIComponent(userId));
return axios.post(url);
}
-
-export function getCurrentUser(options) {
- const url = buildApiUrl(CURRENT_USER_PATH);
- return axios.get(url, { ...options });
-}
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 8381dcec9c3..5ab66acaf80 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -5,7 +5,7 @@ import AccessorUtilities from './lib/utils/accessor';
export default class Autosave {
constructor(field, key, fallbackKey, lockVersion) {
this.field = field;
-
+ this.type = this.field.prop('type');
this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
if (key.join != null) {
key = key.join('/');
@@ -22,11 +22,12 @@ export default class Autosave {
restore() {
if (!this.isLocalStorageAvailable) return;
if (!this.field.length) return;
-
const text = window.localStorage.getItem(this.key);
const fallbackText = window.localStorage.getItem(this.fallbackKey);
- if (text) {
+ if (this.type === 'checkbox') {
+ this.field.prop('checked', text || fallbackText);
+ } else if (text) {
this.field.val(text);
} else if (fallbackText) {
this.field.val(fallbackText);
@@ -49,17 +50,16 @@ export default class Autosave {
save() {
if (!this.field.length) return;
+ const value = this.type === 'checkbox' ? this.field.is(':checked') : this.field.val();
- const text = this.field.val();
-
- if (this.isLocalStorageAvailable && text) {
+ if (this.isLocalStorageAvailable && value) {
if (this.fallbackKey) {
- window.localStorage.setItem(this.fallbackKey, text);
+ window.localStorage.setItem(this.fallbackKey, value);
}
if (this.lockVersion !== undefined) {
window.localStorage.setItem(this.lockVersionKey, this.lockVersion);
}
- return window.localStorage.setItem(this.key, text);
+ return window.localStorage.setItem(this.key, value);
}
return this.reset();
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index a030797c698..a3ffb4df7b7 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -165,6 +165,7 @@ export class AwardsHandler {
`;
const targetEl = this.targetContainerEl ? this.targetContainerEl : document.body;
+ // eslint-disable-next-line no-unsanitized/method
targetEl.insertAdjacentHTML('beforeend', emojiMenuMarkup);
this.addRemainingEmojiMenuCategories();
@@ -198,6 +199,7 @@ export class AwardsHandler {
emojisInCategory,
);
requestAnimationFrame(() => {
+ // eslint-disable-next-line no-unsanitized/method
emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup);
resolve();
});
diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue
index 0eb4e6e7709..71560c7de3a 100644
--- a/app/assets/javascripts/batch_comments/components/preview_item.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_item.vue
@@ -67,6 +67,7 @@ export default {
},
content() {
const el = document.createElement('div');
+ // eslint-disable-next-line no-unsanitized/property
el.innerHTML = this.draft.note_html;
return el.textContent;
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index 54b9953270b..acc3cbe10a0 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -1,7 +1,16 @@
<script>
import $ from 'jquery';
-import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlLink } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlButton,
+ GlIcon,
+ GlForm,
+ GlFormGroup,
+ GlLink,
+ GlFormCheckbox,
+} from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
+import { createAlert } from '~/flash';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
import Autosave from '~/autosave';
@@ -15,29 +24,46 @@ export default {
GlForm,
GlFormGroup,
GlLink,
+ GlFormCheckbox,
MarkdownField,
+ ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'),
},
data() {
return {
isSubmitting: false,
- note: '',
+ noteData: {
+ noteable_type: '',
+ noteable_id: '',
+ note: '',
+ approve: false,
+ approval_password: '',
+ },
};
},
computed: {
...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
},
+ watch: {
+ 'noteData.approve': function noteDataApproveWatch() {
+ setTimeout(() => {
+ this.repositionDropdown();
+ });
+ },
+ },
mounted() {
this.autosave = new Autosave(
$(this.$refs.textarea),
`submit_review_dropdown/${this.getNoteableData.id}`,
);
+ this.noteData.noteable_type = this.noteableType;
+ this.noteData.noteable_id = this.getNoteableData.id;
// We override the Bootstrap Vue click outside behaviour
// to allow for clicking in the autocomplete dropdowns
// without this override the submit dropdown will close
// whenever a item in the autocomplete dropdown is clicked
- const originalClickOutHandler = this.$refs.dropdown.$refs.dropdown.clickOutHandler;
- this.$refs.dropdown.$refs.dropdown.clickOutHandler = (e) => {
+ const originalClickOutHandler = this.$refs.submitDropdown.$refs.dropdown.clickOutHandler;
+ this.$refs.submitDropdown.$refs.dropdown.clickOutHandler = (e) => {
if (!e.target.closest('.atwho-container')) {
originalClickOutHandler(e);
}
@@ -45,26 +71,32 @@ export default {
},
methods: {
...mapActions('batchComments', ['publishReview']),
+ repositionDropdown() {
+ this.$refs.submitDropdown?.$refs.dropdown?.updatePopper();
+ },
async submitReview() {
- const noteData = {
- noteable_type: this.noteableType,
- noteable_id: this.getNoteableData.id,
- note: this.note,
- };
-
this.isSubmitting = true;
- await this.publishReview(noteData);
+ try {
+ await this.publishReview(this.noteData);
+
+ this.autosave.reset();
- this.autosave.reset();
+ if (window.mrTabs && (this.noteData.note || this.noteData.approve)) {
+ if (this.noteData.note) {
+ window.location.hash = `note_${this.getCurrentUserLastNote.id}`;
+ }
- if (window.mrTabs && this.note) {
- window.location.hash = `note_${this.getCurrentUserLastNote.id}`;
- window.mrTabs.tabShown('show');
+ window.mrTabs.tabShown('show');
- setTimeout(() =>
- scrollToElement(document.getElementById(`note_${this.getCurrentUserLastNote.id}`)),
- );
+ setTimeout(() =>
+ scrollToElement(document.getElementById(`note_${this.getCurrentUserLastNote.id}`)),
+ );
+ }
+ } catch (e) {
+ if (e.data?.message) {
+ createAlert({ message: e.data.message, captureError: true });
+ }
}
this.isSubmitting = false;
@@ -79,8 +111,9 @@ export default {
<template>
<gl-dropdown
- ref="dropdown"
+ ref="submitDropdown"
right
+ dropup
class="submit-review-dropdown"
data-qa-selector="submit_review_dropdown"
variant="info"
@@ -110,7 +143,7 @@ export default {
<markdown-field
:is-submitting="isSubmitting"
:add-spacing-classes="false"
- :textarea-value="note"
+ :textarea-value="noteData.note"
:markdown-preview-path="getNoteableData.preview_note_path"
:markdown-docs-path="getNotesData.markdownDocsPath"
:quick-actions-docs-path="getNotesData.quickActionsDocsPath"
@@ -122,7 +155,7 @@ export default {
<textarea
id="review-note-body"
ref="textarea"
- v-model="note"
+ v-model="noteData.note"
dir="auto"
:disabled="isSubmitting"
name="review[note]"
@@ -139,6 +172,18 @@ export default {
</div>
</div>
</gl-form-group>
+ <template v-if="getNoteableData.current_user.can_approve">
+ <gl-form-checkbox v-model="noteData.approve" data-testid="approve_merge_request">
+ {{ __('Approve merge request') }}
+ </gl-form-checkbox>
+ <approval-password
+ v-if="getNoteableData.require_password_to_approve"
+ v-show="noteData.approve"
+ v-model="noteData.approval_password"
+ class="gl-mt-3"
+ data-testid="approve_password"
+ />
+ </template>
<div class="gl-display-flex gl-justify-content-start gl-mt-5">
<gl-button
:loading="isSubmitting"
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index a44b9827fe9..2b0aaa74e83 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -84,7 +84,11 @@ export const publishReview = ({ commit, dispatch, getters }, noteData = {}) => {
.publish(getters.getNotesData.draftsPublishPath, noteData)
.then(() => dispatch('updateDiscussionsAfterPublish'))
.then(() => commit(types.RECEIVE_PUBLISH_REVIEW_SUCCESS))
- .catch(() => commit(types.RECEIVE_PUBLISH_REVIEW_ERROR));
+ .catch((e) => {
+ commit(types.RECEIVE_PUBLISH_REVIEW_ERROR);
+
+ throw e.response;
+ });
};
export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGetters }) => {
diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js
index 6d2a4c245cc..a653769b60f 100644
--- a/app/assets/javascripts/behaviors/copy_code.js
+++ b/app/assets/javascripts/behaviors/copy_code.js
@@ -22,6 +22,7 @@ class CopyCodeButton extends HTMLElement {
'data-clipboard-target': `pre#${this.for}`,
});
+ // eslint-disable-next-line no-unsanitized/property
button.innerHTML = spriteIcon('copy-to-clipboard');
return button;
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index 07fd6dae76a..4b337dce8f3 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -102,8 +102,12 @@ export default function initCopyToClipboard() {
* @param {HTMLElement} btnElement
*/
export function clickCopyToClipboardButton(btnElement) {
- // Ensure the button has already been tooltip'd.
- add([btnElement], { show: true });
+ const { clipboardHandleTooltip = true } = btnElement.dataset;
+
+ if (parseBoolean(clipboardHandleTooltip)) {
+ // Ensure the button has already been tooltip'd.
+ add([btnElement], { show: true });
+ }
btnElement.click();
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index af7aac4cf36..ac41af4df7a 100644
--- a/app/assets/javascripts/behaviors/markdown/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -91,6 +91,7 @@ class SafeMathRenderer {
`;
if (!wrapperElement.classList.contains('lazy-alert-shown')) {
+ // eslint-disable-next-line no-unsanitized/property
wrapperElement.innerHTML = html;
wrapperElement.append(codeElement);
wrapperElement.classList.add('lazy-alert-shown');
@@ -111,6 +112,7 @@ class SafeMathRenderer {
}
try {
+ // eslint-disable-next-line no-unsanitized/property
displayContainer.innerHTML = this.katex.renderToString(text, {
displayMode: el.dataset.mathStyle === 'display',
throwOnError: true,
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 82229b5aa8f..97ba9e15c0f 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -1,9 +1,11 @@
import $ from 'jquery';
+import ClipboardJS from 'clipboard';
import Mousetrap from 'mousetrap';
-import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { isElementVisible } from '~/lib/utils/dom_utils';
import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
+import toast from '~/vue_shared/plugins/global_toast';
+import { s__ } from '~/locale';
import Sidebar from '~/right_sidebar';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
import {
@@ -21,6 +23,15 @@ export default class ShortcutsIssuable extends Shortcuts {
constructor() {
super();
+ this.inMemoryButton = document.createElement('button');
+ this.clipboardInstance = new ClipboardJS(this.inMemoryButton);
+ this.clipboardInstance.on('success', () => {
+ toast(s__('GlobalShortcuts|Copied source branch name to clipboard.'));
+ });
+ this.clipboardInstance.on('error', () => {
+ toast(s__('GlobalShortcuts|Unable to copy the source branch name at this time.'));
+ });
+
Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_ASSIGNEE), () =>
ShortcutsIssuable.openSidebarDropdown('assignee'),
);
@@ -32,7 +43,7 @@ export default class ShortcutsIssuable extends Shortcuts {
);
Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), ShortcutsIssuable.replyWithSelectedText);
Mousetrap.bind(keysFor(ISSUABLE_EDIT_DESCRIPTION), ShortcutsIssuable.editIssue);
- Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), ShortcutsIssuable.copyBranchName);
+ Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), () => this.copyBranchName());
/**
* We're attaching a global focus event listener on document for
@@ -153,17 +164,14 @@ export default class ShortcutsIssuable extends Shortcuts {
return false;
}
- static copyBranchName() {
- // There are two buttons - one that is shown when the sidebar
- // is expanded, and one that is shown when it's collapsed.
- const allCopyBtns = Array.from(document.querySelectorAll('.js-source-branch-copy'));
+ async copyBranchName() {
+ const button = document.querySelector('.js-source-branch-copy');
+ const branchName = button?.dataset.clipboardText;
- // Select whichever button is currently visible so that
- // the "Copied" tooltip is shown when a click is simulated.
- const visibleBtn = allCopyBtns.find(isElementVisible);
+ if (branchName) {
+ this.inMemoryButton.dataset.clipboardText = branchName;
- if (visibleBtn) {
- clickCopyToClipboardButton(visibleBtn);
+ this.inMemoryButton.dispatchEvent(new CustomEvent('click'));
}
}
}
diff --git a/app/assets/javascripts/blob/3d_viewer/index.js b/app/assets/javascripts/blob/3d_viewer/index.js
index d4efe409fef..2831c37838b 100644
--- a/app/assets/javascripts/blob/3d_viewer/index.js
+++ b/app/assets/javascripts/blob/3d_viewer/index.js
@@ -1,11 +1,8 @@
-import OrbitControlsClass from 'three-orbit-controls';
-import STLLoaderClass from 'three-stl-loader';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
+import { STLLoader } from 'three/examples/jsm/loaders/STLLoader';
import * as THREE from 'three/build/three.module';
import MeshObject from './mesh_object';
-const STLLoader = STLLoaderClass(THREE);
-const OrbitControls = OrbitControlsClass(THREE);
-
export default class Renderer {
constructor(container) {
this.renderWrapper = this.render.bind(this);
diff --git a/app/assets/javascripts/blob/3d_viewer/mesh_object.js b/app/assets/javascripts/blob/3d_viewer/mesh_object.js
index c55a9ca8926..5322dc00e86 100644
--- a/app/assets/javascripts/blob/3d_viewer/mesh_object.js
+++ b/app/assets/javascripts/blob/3d_viewer/mesh_object.js
@@ -22,7 +22,7 @@ export default class MeshObject extends Mesh {
if (this.geometry.boundingSphere.radius > 4) {
const scale = 4 / this.geometry.boundingSphere.radius;
- this.geometry.applyMatrix(new Matrix4().makeScale(scale, scale, scale));
+ this.geometry.applyMatrix4(new Matrix4().makeScale(scale, scale, scale));
this.geometry.computeBoundingSphere();
this.position.x = -this.geometry.boundingSphere.center.x;
diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
index d2a841c88f1..dc1a9cb865a 100644
--- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue
+++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
@@ -1,11 +1,11 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
-import notebookLab from '~/notebook/index.vue';
+import NotebookLab from '~/notebook/index.vue';
export default {
components: {
- notebookLab,
+ NotebookLab,
GlLoadingIcon,
},
props: {
@@ -66,7 +66,7 @@ export default {
<div v-if="loading && !error" class="text-center loading">
<gl-loading-icon class="mt-5" size="lg" />
</div>
- <notebook-lab v-if="!loading && !error" :notebook="json" code-css-class="code white" />
+ <notebook-lab v-if="!loading && !error" :notebook="json" />
<p v-if="error" class="text-center">
<span v-if="loadError" ref="loadErrorMessage">{{
__('An error occurred while loading the file. Please try again later.')
diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js
index a92161bbc1b..bb29224cda2 100644
--- a/app/assets/javascripts/blob/sketch/index.js
+++ b/app/assets/javascripts/blob/sketch/index.js
@@ -1,5 +1,5 @@
import JSZip from 'jszip';
-import JSZipUtils from 'jszip-utils';
+import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
export default class SketchLoader {
@@ -7,35 +7,28 @@ export default class SketchLoader {
this.container = container;
this.loadingIcon = this.container.querySelector('.js-loading-icon');
- this.load();
+ this.load().catch(() => {
+ this.error();
+ });
}
- load() {
- return this.getZipFile()
- .then((data) => JSZip.loadAsync(data))
- .then((asyncResult) => asyncResult.files['previews/preview.png'].async('uint8array'))
- .then((content) => {
- const url = window.URL || window.webkitURL;
- const blob = new Blob([new Uint8Array(content)], {
- type: 'image/png',
- });
- const previewUrl = url.createObjectURL(blob);
+ async load() {
+ const zipContents = await this.getZipContents();
+ const previewContents = await zipContents.files['previews/preview.png'].async('uint8array');
+
+ const blob = new Blob([previewContents], {
+ type: 'image/png',
+ });
- this.render(previewUrl);
- })
- .catch(this.error.bind(this));
+ this.render(window.URL.createObjectURL(blob));
}
- getZipFile() {
- return new Promise((resolve, reject) => {
- JSZipUtils.getBinaryContent(this.container.dataset.endpoint, (err, data) => {
- if (err) {
- reject(err);
- } else {
- resolve(data);
- }
- });
+ async getZipContents() {
+ const { data } = await axios.get(this.container.dataset.endpoint, {
+ responseType: 'arraybuffer',
});
+
+ return JSZip.loadAsync(data);
}
render(previewUrl) {
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index a0d4f7ef4f2..5ca3f131d99 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -45,6 +45,7 @@ const loadViewer = (viewerParam) => {
viewer.dataset.loading = 'true';
return axios.get(url).then(({ data }) => {
+ // eslint-disable-next-line no-unsanitized/property
viewer.innerHTML = data.html;
window.requestIdleCallback(() => {
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 425de914c17..d73e1cc43b0 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -63,6 +63,7 @@ export default () => {
const isMarkdown = editBlobForm.data('is-markdown');
const previewMarkdownPath = editBlobForm.data('previewMarkdownPath');
const commitButton = $('.js-commit-button');
+ const commitButtonLoading = $('.js-commit-button-loading');
const cancelLink = $('#cancel-changes');
import('./edit_blob')
@@ -88,6 +89,8 @@ export default () => {
});
commitButton.on('click', () => {
+ commitButton.addClass('gl-display-none');
+ commitButtonLoading.removeClass('gl-display-none');
window.onbeforeunload = null;
});
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
index c4a2f83ab50..1899d42fa4d 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
@@ -102,7 +102,7 @@ export default {
data-qa-selector="board_add_new_list"
>
<div
- class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
+ class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50"
>
<h3 class="gl-font-size-h2 gl-px-5 gl-py-5 gl-m-0" data-testid="board-add-column-form-title">
{{ $options.i18n.newList }}
diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue
index b81edb4dfe6..3f8a596abd8 100644
--- a/app/assets/javascripts/boards/components/board_blocked_icon.vue
+++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
-import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { truncate } from '~/lib/utils/text_utility';
import { __, n__, s__, sprintf } from '~/locale';
@@ -10,10 +10,12 @@ export default {
i18n: {
issuableType: {
[issuableTypes.issue]: __('issue'),
+ [issuableTypes.epic]: __('epic'),
},
},
graphQLIdType: {
[issuableTypes.issue]: TYPE_ISSUE,
+ [issuableTypes.epic]: TYPE_EPIC,
},
referenceFormatter: {
[issuableTypes.issue]: (r) => r.split('/')[1],
@@ -40,7 +42,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [issuableTypes.issue].includes(value);
+ return [issuableTypes.issue, issuableTypes.epic].includes(value);
},
},
},
@@ -53,14 +55,21 @@ export default {
return blockingIssuablesQueries[this.issuableType].query;
},
variables() {
+ if (this.isEpic) {
+ return {
+ fullPath: this.item.group.fullPath,
+ iid: Number(this.item.iid),
+ };
+ }
return {
id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id),
};
},
update(data) {
this.skip = true;
+ const issuable = this.isEpic ? data?.group?.issuable : data?.issuable;
- return data?.issuable?.blockingIssuables?.nodes || [];
+ return issuable?.blockingIssuables?.nodes || [];
},
error(error) {
const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), {
@@ -77,13 +86,16 @@ export default {
};
},
computed: {
+ isEpic() {
+ return this.issuableType === issuableTypes.epic;
+ },
displayedIssuables() {
const { defaultDisplayLimit, referenceFormatter } = this.$options;
return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => {
return {
...i,
title: truncate(i.title, this.$options.textTruncateWidth),
- reference: referenceFormatter[this.issuableType](i.reference),
+ reference: this.isEpic ? i.reference : referenceFormatter[this.issuableType](i.reference),
};
});
},
@@ -106,6 +118,9 @@ export default {
},
);
},
+ blockIcon() {
+ return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked';
+ },
glIconId() {
return `blocked-icon-${this.uniqueId}`;
},
@@ -153,8 +168,8 @@ export default {
<gl-icon
:id="glIconId"
ref="icon"
- name="issue-block"
- class="issue-blocked-icon gl-mr-2 gl-cursor-pointer"
+ :name="blockIcon"
+ class="issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500"
data-testid="issue-blocked-icon"
@mouseenter="handleMouseEnter"
/>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 3638fdd2ca5..44c16324950 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -30,6 +30,11 @@ export default {
default: 0,
required: false,
},
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
computed: {
...mapState(['selectedBoardItems', 'activeId']),
@@ -81,10 +86,10 @@ export default {
data-qa-selector="board_card"
:class="[
{
- 'multi-select': multiSelectVisible,
+ 'multi-select gl-bg-blue-50 gl-border-blue-200': multiSelectVisible,
'gl-cursor-grab': isDraggable,
'is-disabled': isDisabled,
- 'is-active': isActive,
+ 'is-active gl-bg-blue-50': isActive,
'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading,
},
colorClass,
@@ -95,9 +100,15 @@ export default {
:data-item-path="item.referencePath"
:style="cardStyle"
data-testid="board_card"
- class="board-card gl-p-5 gl-rounded-base"
+ class="board-card gl-p-5 gl-rounded-base gl-line-height-normal gl-relative gl-mb-3"
@click="toggleIssue($event)"
>
- <board-card-inner :list="list" :item="item" :update-filters="true" />
+ <board-card-inner
+ :list="list"
+ :item="item"
+ :update-filters="true"
+ :index="index"
+ :show-work-item-type-icon="showWorkItemTypeIcon"
+ />
</li>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 8dc521317cd..92a623d65d4 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -15,6 +15,8 @@ import { updateHistory } from '~/lib/utils/url_utility';
import { sprintf, __, n__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import { ListType } from '../constants';
import eventHub from '../eventhub';
import BoardBlockedIcon from './board_blocked_icon.vue';
@@ -34,6 +36,10 @@ export default {
IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
BoardBlockedIcon,
GlSprintf,
+ BoardCardMoveToPosition,
+ WorkItemTypeIcon,
+ IssueHealthStatus: () =>
+ import('ee_component/related_items_tree/components/issue_health_status.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -55,6 +61,15 @@ export default {
required: false,
default: false,
},
+ index: {
+ type: Number,
+ required: true,
+ },
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -202,7 +217,7 @@ export default {
<template>
<div>
<div class="gl-display-flex" dir="auto">
- <h4 class="board-card-title gl-mb-0 gl-mt-0">
+ <h4 class="board-card-title gl-mb-0 gl-mt-0 gl-mr-3 gl-font-base gl-overflow-break-word">
<board-blocked-icon
v-if="item.blocked"
:item="item"
@@ -215,7 +230,7 @@ export default {
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
- class="confidential-icon gl-mr-2"
+ class="confidential-icon gl-mr-2 gl-text-orange-500 gl-cursor-help"
:aria-label="__('Confidential')"
/>
<gl-icon
@@ -223,24 +238,25 @@ export default {
v-gl-tooltip
name="spam"
:title="__('This issue is hidden because its author has been banned')"
- class="gl-mr-2 hidden-icon"
+ class="gl-mr-2 hidden-icon gl-text-orange-500 gl-cursor-help"
data-testid="hidden-icon"
/>
<a
:href="item.path || item.webUrl || ''"
:title="item.title"
:class="{ 'gl-text-gray-400!': item.isLoading }"
- class="js-no-trigger"
+ class="js-no-trigger gl-text-body gl-hover-text-gray-900"
@mousemove.stop
>{{ item.title }}</a
>
</h4>
+ <board-card-move-to-position :item="item" :list="list" :index="index" />
</div>
<div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
<template v-for="label in orderedLabels">
<gl-label
:key="label.id"
- class="js-no-trigger"
+ class="js-no-trigger gl-mt-2 gl-mr-2"
:background-color="label.color"
:title="label.title"
:description="label.description"
@@ -260,9 +276,14 @@ export default {
<gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" />
<span
v-if="item.referencePath"
- class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
+ class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-text-secondary"
:class="{ 'gl-font-base': isEpicBoard }"
>
+ <work-item-type-icon
+ v-if="showWorkItemTypeIcon"
+ :work-item-type="item.type"
+ show-tooltip-on-hover
+ />
<tooltip-on-truncate
v-if="showReferencePath"
:title="itemReferencePath"
@@ -321,7 +342,10 @@ export default {
</p>
</gl-tooltip>
- <span ref="countBadge" class="board-card-info gl-mr-0 gl-pr-0 gl-pl-3">
+ <span
+ ref="countBadge"
+ class="board-card-info gl-mr-0 gl-pr-0 gl-pl-3 gl-text-secondary gl-cursor-help"
+ >
<span v-if="allowSubEpics" class="gl-mr-3">
<gl-icon name="epic" />
{{ totalEpicsCount }}
@@ -339,7 +363,7 @@ export default {
<span
v-if="shouldRenderEpicProgress"
ref="progressBadge"
- class="board-card-info gl-pl-0"
+ class="board-card-info gl-pl-0 gl-text-secondary gl-cursor-help"
>
<span class="gl-mr-3" data-testid="epic-progress">
<gl-icon name="progress" />
@@ -359,10 +383,11 @@ export default {
:weight="item.weight"
@click="filterByWeight(item.weight)"
/>
+ <issue-health-status v-if="item.healthStatus" :health-status="item.healthStatus" />
</span>
</span>
</div>
- <div class="board-card-assignee gl-display-flex gl-gap-3">
+ <div class="board-card-assignee gl-display-flex gl-gap-3 gl-mb-n2">
<user-avatar-link
v-for="assignee in cappedAssignees"
:key="assignee.id"
@@ -370,7 +395,7 @@ export default {
:img-alt="avatarUrlTitle(assignee)"
:img-src="avatarUrl(assignee)"
:img-size="avatarSize"
- class="js-no-trigger"
+ class="js-no-trigger user-avatar-link"
tooltip-placement="bottom"
:enforce-gl-avatar="true"
>
@@ -384,7 +409,7 @@ export default {
v-if="shouldRenderCounter"
v-gl-tooltip
:title="assigneeCounterTooltip"
- class="avatar-counter"
+ class="avatar-counter gl-bg-gray-400 gl-cursor-help gl-font-weight-bold gl-ml-n4 gl-border-0 gl-line-height-24"
data-placement="bottom"
>{{ assigneeCounterLabel }}</span
>
diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
new file mode 100644
index 00000000000..ff938219475
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
@@ -0,0 +1,128 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { s__ } from '~/locale';
+
+import Tracking from '~/tracking';
+
+export default {
+ i18n: {
+ moveToStartText: s__('Boards|Move to start of list'),
+ moveToEndText: s__('Boards|Move to end of list'),
+ },
+ name: 'BoardCardMoveToPosition',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ validator: (item) => ['id', 'iid', 'referencePath'].every((key) => item[key]),
+ },
+ list: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['pageInfoByListId']),
+ ...mapGetters(['getBoardItemsByList']),
+ tracking() {
+ return {
+ category: 'boards:list',
+ label: 'move_to_position',
+ property: `type_card`,
+ };
+ },
+ listItems() {
+ return this.getBoardItemsByList(this.list.id);
+ },
+ listHasNextPage() {
+ return this.pageInfoByListId[this.list.id]?.hasNextPage;
+ },
+ lengthOfListItemsInBoard() {
+ return this.listItems?.length;
+ },
+ itemIdentifier() {
+ return `${this.item.id}-${this.item.iid}-${this.index}`;
+ },
+ isFirstItemInList() {
+ return this.index === 0;
+ },
+ isLastItemInList() {
+ return this.index === this.lengthOfListItemsInBoard - 1;
+ },
+ },
+ methods: {
+ ...mapActions(['moveItem']),
+ moveToStart() {
+ this.track('click_toggle_button', {
+ label: 'move_to_start',
+ });
+ /** in case it is the first in the list don't call any action/mutation * */
+ if (this.isFirstItemInList) {
+ return;
+ }
+ this.moveToPosition({
+ positionInList: 0,
+ });
+ },
+ moveToEnd() {
+ this.track('click_toggle_button', {
+ label: 'move_to_end',
+ });
+ /** in case it is the last in the list don't call any action/mutation * */
+ if (this.isLastItemInList) {
+ return;
+ }
+ this.moveToPosition({
+ positionInList: -1,
+ });
+ },
+ moveToPosition({ positionInList }) {
+ this.moveItem({
+ itemId: this.item.id,
+ itemIid: this.item.iid,
+ itemPath: this.item.referencePath,
+ fromListId: this.list.id,
+ toListId: this.list.id,
+ positionInList,
+ atIndex: this.index,
+ allItemsLoadedInList: !this.listHasNextPage,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ :key="itemIdentifier"
+ icon="ellipsis_v"
+ :text="s__('Boards|Move card')"
+ :text-sr-only="true"
+ class="move-to-position gl-display-block gl-mb-2 gl-ml-2 gl-mt-n3 gl-mr-n3"
+ category="tertiary"
+ :tabindex="index"
+ no-caret
+ @keydown.esc.native="$emit('hide')"
+ >
+ <div>
+ <gl-dropdown-item @click.stop="moveToStart">
+ {{ $options.i18n.moveToStartText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click.stop="moveToEnd">
+ {{ $options.i18n.moveToEndText }}
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index bcf5b12b209..8fc76c02e14 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -76,7 +76,7 @@ export default {
<div
:class="{
'is-draggable': isListDraggable,
- 'is-collapsed': list.collapsed,
+ 'is-collapsed gl-w-10': list.collapsed,
'board-type-assignee': list.listType === 'assignee',
}"
:data-list-id="list.id"
@@ -84,7 +84,7 @@ export default {
data-qa-selector="board_list"
>
<div
- class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
+ class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50"
:class="{ 'board-column-highlighted': highlighted }"
>
<board-list-header :list="list" :disabled="disabled" />
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 8868b9b2f3e..d99afa8455d 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -75,7 +75,7 @@ export default {
v-if="!isSwimlanesOn"
ref="list"
v-bind="draggableOptions"
- class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap"
+ class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-scroll"
@end="moveList"
>
<board-column
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index d25169b5b9d..00b4e6c96a9 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -57,6 +57,9 @@ export default {
labelsFilterBasePath: {
default: '',
},
+ canUpdate: {
+ default: false,
+ },
},
inheritAttrs: false,
computed: {
@@ -163,6 +166,7 @@ export default {
:full-path="fullPath"
:initial-assignees="activeBoardItem.assignees"
:allow-multiple-assignees="multipleAssigneesFeatureAvailable"
+ :editable="canUpdate"
@assignees-updated="setAssignees"
/>
<sidebar-dropdown-widget
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 66388f4eb43..edf1a5ee7e6 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -66,7 +66,7 @@ export default {
},
},
computed: {
- ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams']),
+ ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams', 'isUpdateIssueOrderInProgress']),
...mapGetters(['isEpicBoard']),
listItemsCount() {
return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount;
@@ -132,6 +132,9 @@ export default {
return this.canMoveIssue ? options : {};
},
+ disableScrollingWhenMutationInProgress() {
+ return this.hasNextPage && this.isUpdateIssueOrderInProgress;
+ },
},
watch: {
boardItems() {
@@ -265,7 +268,7 @@ export default {
<template>
<div
v-show="!list.collapsed"
- class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column"
+ class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column gl-min-h-0"
data-qa-selector="board_list_cards_area"
>
<div
@@ -285,9 +288,13 @@ export default {
v-bind="treeRootOptions"
:data-board="list.id"
:data-board-type="list.listType"
- :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }"
+ :class="{
+ 'bg-danger-100': boardItemsSizeExceedsMax,
+ 'gl-overflow-hidden': disableScrollingWhenMutationInProgress,
+ 'gl-overflow-y-auto': !disableScrollingWhenMutationInProgress,
+ }"
draggable=".board-card"
- class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-3 gl-pt-0"
+ class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-3 gl-pt-0 gl-overflow-x-hidden"
data-testid="tree-root-wrapper"
@start="handleDragOnStart"
@end="handleDragOnEnd"
@@ -301,9 +308,14 @@ export default {
:item="item"
:data-draggable-item-type="$options.draggableItemTypes.card"
:disabled="disabled"
+ :show-work-item-type-icon="!isEpicBoard"
/>
<gl-intersection-observer @appear="onReachingListBottom">
- <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
+ <li
+ v-if="showCount"
+ class="board-list-count gl-text-center gl-text-secondary gl-py-4"
+ data-issue-id="-1"
+ >
<gl-loading-icon
v-if="loadingMore"
size="sm"
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index e3012f5b36d..230fa4e1e0f 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -252,7 +252,7 @@ export default {
<header
:class="{
'gl-h-full': list.collapsed,
- 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
+ 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base gl-bg-gray-50': isSwimlanesHeader,
}"
:style="headerStyle"
class="board-header gl-relative"
@@ -267,14 +267,15 @@ export default {
'gl-py-2': list.collapsed && isSwimlanesHeader,
'gl-flex-direction-column': list.collapsed,
}"
- class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3"
+ class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 gl-h-9"
>
<gl-button
v-gl-tooltip.hover
:aria-label="chevronTooltip"
:title="chevronTooltip"
:icon="chevronIcon"
- class="board-title-caret no-drag gl-cursor-pointer"
+ class="board-title-caret no-drag gl-cursor-pointer gl-hover-bg-gray-50"
+ :class="{ 'gl-mt-1': list.collapsed, 'gl-mr-2': !list.collapsed }"
category="tertiary"
size="small"
data-testid="board-title-caret"
@@ -307,6 +308,7 @@ export default {
'gl-display-none': list.collapsed && isSwimlanesHeader,
'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed,
'gl-flex-grow-1': !list.collapsed,
+ 'gl-rotate-90': list.collapsed,
}"
>
<!-- EE start -->
@@ -324,7 +326,7 @@ export default {
<span
v-if="listType === 'assignee'"
v-show="!list.collapsed"
- class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
+ class="gl-ml-2 gl-font-weight-normal gl-text-secondary"
>
@{{ listAssignee }}
</span>
@@ -345,7 +347,7 @@ export default {
v-if="isSwimlanesHeader && list.collapsed"
ref="collapsedInfo"
aria-hidden="true"
- class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-gray-500"
+ class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-secondary gl-hover-text-gray-900"
>
<gl-icon name="information" />
</span>
@@ -369,14 +371,14 @@ export default {
<!-- EE end -->
<div
- class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-gray-500"
+ class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-secondary"
data-testid="issue-count-badge"
:class="{
'gl-display-none!': list.collapsed && isSwimlanesHeader,
'gl-p-0': list.collapsed,
}"
>
- <span class="gl-display-inline-flex">
+ <span class="gl-display-inline-flex" :class="{ 'gl-rotate-90': list.collapsed }">
<gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
<span ref="itemCount" class="gl-display-inline-flex gl-align-items-center">
<gl-icon class="gl-mr-2" :name="countIcon" :size="16" />
diff --git a/app/assets/javascripts/boards/components/board_new_item.vue b/app/assets/javascripts/boards/components/board_new_item.vue
index 600917683cd..084b7519d1f 100644
--- a/app/assets/javascripts/boards/components/board_new_item.vue
+++ b/app/assets/javascripts/boards/components/board_new_item.vue
@@ -69,7 +69,7 @@ export default {
</script>
<template>
- <div class="board-new-issue-form">
+ <div class="board-new-issue-form gl-z-index-3 gl-m-3">
<div class="board-card position-relative gl-p-5 rounded">
<gl-form @submit.prevent="handleFormSubmit" @reset="handleFormCancel">
<label :for="inputFieldId" class="gl-font-weight-bold">{{ __('Title') }}</label>
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index 73ec008c2b6..b09b1d48ca5 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltip, GlIcon } from '@gitlab/ui';
-import dateFormat from 'dateformat';
+import dateFormat from '~/lib/dateformat';
import {
getDayDifference,
getTimeago,
@@ -85,7 +85,11 @@ export default {
<template>
<span>
- <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
+ <span
+ ref="issueDueDate"
+ :class="cssClass"
+ class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help"
+ >
<gl-icon
:class="{ 'text-danger': isPastDue }"
class="board-card-info-icon gl-mr-2"
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index 9312db06efe..bc12717a92d 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -36,7 +36,7 @@ export default {
<template>
<span>
- <span ref="issueTimeEstimate" class="board-card-info card-number">
+ <span ref="issueTimeEstimate" class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help">
<gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" />
<time class="board-card-info-text">{{ timeEstimate }}</time>
</span>
diff --git a/app/assets/javascripts/boards/components/item_count.vue b/app/assets/javascripts/boards/components/item_count.vue
index a11c23e5625..dab82abb646 100644
--- a/app/assets/javascripts/boards/components/item_count.vue
+++ b/app/assets/javascripts/boards/components/item_count.vue
@@ -30,7 +30,9 @@ export default {
{{ itemsSize }}
</span>
<span v-if="isMaxLimitSet" class="max-issue-size">
- {{ maxIssueCount }}
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ {{ `/ ${maxIssueCount}` }}
+ <!-- eslint-enable @gitlab/vue-require-i18n-strings -->
</span>
</div>
</template>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 0f290f566ba..ed22a375271 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -3,6 +3,7 @@ import { __ } from '~/locale';
import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql';
+import boardBlockingEpicsQuery from './graphql/board_blocking_epics.query.graphql';
import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from './graphql/board_list_update.mutation.graphql';
@@ -70,6 +71,9 @@ export const blockingIssuablesQueries = {
[issuableTypes.issue]: {
query: boardBlockingIssuesQuery,
},
+ [issuableTypes.epic]: {
+ query: boardBlockingEpicsQuery,
+ },
};
export const updateListQueries = {
@@ -146,3 +150,5 @@ export default {
BoardType,
ListType,
};
+
+export const DEFAULT_BOARD_LIST_ITEMS_SIZE = 10;
diff --git a/app/assets/javascripts/boards/filters/due_date_filters.js b/app/assets/javascripts/boards/filters/due_date_filters.js
index 1745ab3bab4..a452d32ef15 100644
--- a/app/assets/javascripts/boards/filters/due_date_filters.js
+++ b/app/assets/javascripts/boards/filters/due_date_filters.js
@@ -1,5 +1,5 @@
-import dateFormat from 'dateformat';
import Vue from 'vue';
+import dateFormat from '~/lib/dateformat';
Vue.filter('due-date', (value) => {
const date = new Date(value);
diff --git a/app/assets/javascripts/boards/graphql/board.fragment.graphql b/app/assets/javascripts/boards/graphql/board.fragment.graphql
deleted file mode 100644
index 872a4c4afbc..00000000000
--- a/app/assets/javascripts/boards/graphql/board.fragment.graphql
+++ /dev/null
@@ -1,4 +0,0 @@
-fragment BoardFragment on Board {
- id
- name
-}
diff --git a/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql
new file mode 100644
index 00000000000..071a6d7410f
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql
@@ -0,0 +1,17 @@
+query BoardBlockingEpics($fullPath: ID!, $iid: ID) {
+ group(fullPath: $fullPath) {
+ id
+ issuable: epic(iid: $iid) {
+ id
+ blockingIssuables: blockedByEpics {
+ nodes {
+ id
+ iid
+ title
+ reference(full: true)
+ webUrl
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/group_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_boards.query.graphql
index 0823c4f5a83..ce9f7bbfd2a 100644
--- a/app/assets/javascripts/boards/graphql/group_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_boards.query.graphql
@@ -1,12 +1,11 @@
-#import "ee_else_ce/boards/graphql/board.fragment.graphql"
-
query group_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
id
boards {
edges {
node {
- ...BoardFragment
+ id
+ name
}
}
}
diff --git a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
index 827c08486b1..b9fe778d4d4 100644
--- a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
@@ -1,12 +1,11 @@
-#import "ee_else_ce/boards/graphql/board.fragment.graphql"
-
query group_recent_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
id
recentIssueBoards {
edges {
node {
- ...BoardFragment
+ id
+ name
}
}
}
diff --git a/app/assets/javascripts/boards/graphql/project_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_boards.query.graphql
index b8879bc260c..770c246a95b 100644
--- a/app/assets/javascripts/boards/graphql/project_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_boards.query.graphql
@@ -1,12 +1,11 @@
-#import "ee_else_ce/boards/graphql/board.fragment.graphql"
-
query project_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
id
boards {
edges {
node {
- ...BoardFragment
+ id
+ name
}
}
}
diff --git a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
index 4d38e9b0498..c633107a409 100644
--- a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
@@ -1,12 +1,11 @@
-#import "ee_else_ce/boards/graphql/board.fragment.graphql"
-
query project_recent_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
id
recentIssueBoards {
edges {
node {
- ...BoardFragment
+ id
+ name
}
}
}
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 791182af806..c2e346da606 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -15,6 +15,7 @@ import {
FilterFields,
ListTypeTitles,
DraggableItemTypes,
+ DEFAULT_BOARD_LIST_ITEMS_SIZE,
} from 'ee_else_ce/boards/constants';
import {
formatIssueInput,
@@ -429,7 +430,7 @@ export default {
filters: filterParams,
isGroup: boardType === BoardType.group,
isProject: boardType === BoardType.project,
- first: 10,
+ first: DEFAULT_BOARD_LIST_ITEMS_SIZE,
after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined,
};
@@ -478,16 +479,25 @@ export default {
toListId,
moveBeforeId,
moveAfterId,
+ positionInList,
+ allItemsLoadedInList,
} = moveData;
commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
+ if (reordering && !allItemsLoadedInList && positionInList === -1) {
+ return;
+ }
+
if (reordering) {
commit(types.ADD_BOARD_ITEM_TO_LIST, {
itemId,
listId: toListId,
moveBeforeId,
moveAfterId,
+ positionInList,
+ atIndex: originalIndex,
+ allItemsLoadedInList,
});
return;
@@ -499,6 +509,7 @@ export default {
listId: toListId,
moveBeforeId,
moveAfterId,
+ positionInList,
});
}
@@ -552,7 +563,15 @@ export default {
updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => {
try {
- const { itemId, fromListId, toListId, moveBeforeId, moveAfterId, itemNotInToList } = moveData;
+ const {
+ itemId,
+ fromListId,
+ toListId,
+ moveBeforeId,
+ moveAfterId,
+ itemNotInToList,
+ positionInList,
+ } = moveData;
const {
fullBoardId,
filterParams,
@@ -561,6 +580,8 @@ export default {
},
} = state;
+ commit(types.MUTATE_ISSUE_IN_PROGRESS, true);
+
const { data } = await gqlClient.mutate({
mutation: issueMoveListMutation,
variables: {
@@ -571,6 +592,7 @@ export default {
toListId: getIdFromGraphQLId(toListId),
moveBeforeId: moveBeforeId ? getIdFromGraphQLId(moveBeforeId) : undefined,
moveAfterId: moveAfterId ? getIdFromGraphQLId(moveAfterId) : undefined,
+ positionInList,
// 'mutationVariables' allows EE code to pass in extra parameters.
...mutationVariables,
},
@@ -642,7 +664,9 @@ export default {
}
commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue });
+ commit(types.MUTATE_ISSUE_IN_PROGRESS, false);
} catch {
+ commit(types.MUTATE_ISSUE_IN_PROGRESS, false);
commit(
types.SET_ERROR,
s__('Boards|An error occurred while moving the issue. Please try again.'),
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 43268f21f96..0e496677b7b 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -44,3 +44,4 @@ export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
export const SET_ERROR = 'SET_ERROR';
+export const MUTATE_ISSUE_IN_PROGRESS = 'MUTATE_ISSUE_IN_PROGRESS';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 26a98a645b3..44abb2030c7 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -20,17 +20,28 @@ export const removeItemFromList = ({ state, listId, itemId }) => {
updateListItemsCount({ state, listId, value: -1 });
};
-export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }) => {
+export const addItemToList = ({
+ state,
+ listId,
+ itemId,
+ moveBeforeId,
+ moveAfterId,
+ atIndex,
+ positionInList,
+}) => {
const listIssues = state.boardItemsByListId[listId];
let newIndex = atIndex || 0;
+ const moveToStartOrLast = positionInList !== undefined;
if (moveBeforeId) {
newIndex = listIssues.indexOf(moveBeforeId) + 1;
} else if (moveAfterId) {
newIndex = listIssues.indexOf(moveAfterId);
+ } else if (moveToStartOrLast) {
+ newIndex = positionInList === -1 ? listIssues.length : 0;
}
listIssues.splice(newIndex, 0, itemId);
Vue.set(state.boardItemsByListId, listId, listIssues);
- updateListItemsCount({ state, listId, value: 1 });
+ updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 });
};
export default {
@@ -205,12 +216,34 @@ export default {
Vue.set(state.boardItems, issue.id, formatIssue(issue));
},
+ [mutationTypes.MUTATE_ISSUE_IN_PROGRESS](state, isLoading) {
+ state.isUpdateIssueOrderInProgress = isLoading;
+ },
+
[mutationTypes.ADD_BOARD_ITEM_TO_LIST]: (
state,
- { itemId, listId, moveBeforeId, moveAfterId, atIndex, inProgress = false },
+ {
+ itemId,
+ listId,
+ moveBeforeId,
+ moveAfterId,
+ atIndex,
+ positionInList,
+ allItemsLoadedInList,
+ inProgress = false,
+ },
) => {
Vue.set(state.listsFlags, listId, { ...state.listsFlags, addItemToListInProgress: inProgress });
- addItemToList({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex });
+ addItemToList({
+ state,
+ listId,
+ itemId,
+ moveBeforeId,
+ moveAfterId,
+ atIndex,
+ positionInList,
+ allItemsLoadedInList,
+ });
},
[mutationTypes.REMOVE_BOARD_ITEM_FROM_LIST]: (state, { itemId, listId }) => {
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index b62c032b921..bf3f777ea7d 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -40,4 +40,5 @@ export default () => ({
},
// TODO: remove after ce/ee split of board_content.vue
isShowingEpicsSwimlanes: false,
+ isUpdateIssueOrderInProgress: false,
});
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
index 83bad9eb518..59ddf4b19d8 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
@@ -11,11 +11,11 @@ import {
import addAdminVariable from '../graphql/mutations/admin_add_variable.mutation.graphql';
import deleteAdminVariable from '../graphql/mutations/admin_delete_variable.mutation.graphql';
import updateAdminVariable from '../graphql/mutations/admin_update_variable.mutation.graphql';
-import ciVariableSettings from './ci_variable_settings.vue';
+import CiVariableSettings from './ci_variable_settings.vue';
export default {
components: {
- ciVariableSettings,
+ CiVariableSettings,
},
inject: ['endpoint'],
data() {
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
index 3af83ffa8ed..3522243e3e7 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
@@ -14,11 +14,11 @@ import {
import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql';
import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql';
import updateGroupVariable from '../graphql/mutations/group_update_variable.mutation.graphql';
-import ciVariableSettings from './ci_variable_settings.vue';
+import CiVariableSettings from './ci_variable_settings.vue';
export default {
components: {
- ciVariableSettings,
+ CiVariableSettings,
},
mixins: [glFeatureFlagsMixin()],
inject: ['endpoint', 'groupPath', 'groupId'],
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
new file mode 100644
index 00000000000..29db02a3c59
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
@@ -0,0 +1,120 @@
+<script>
+import createFlash from '~/flash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql';
+import getProjectVariables from '../graphql/queries/project_variables.query.graphql';
+import { mapEnvironmentNames } from '../utils';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ GRAPHQL_PROJECT_TYPE,
+ UPDATE_MUTATION_ACTION,
+ environmentFetchErrorText,
+ genericMutationErrorText,
+ variableFetchErrorText,
+} from '../constants';
+import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql';
+import deleteProjectVariable from '../graphql/mutations/project_delete_variable.mutation.graphql';
+import updateProjectVariable from '../graphql/mutations/project_update_variable.mutation.graphql';
+import CiVariableSettings from './ci_variable_settings.vue';
+
+export default {
+ components: {
+ CiVariableSettings,
+ },
+ inject: ['endpoint', 'projectFullPath', 'projectId'],
+ data() {
+ return {
+ projectEnvironments: [],
+ projectVariables: [],
+ };
+ },
+ apollo: {
+ projectEnvironments: {
+ query: getProjectEnvironments,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ };
+ },
+ update(data) {
+ return mapEnvironmentNames(data?.project?.environments?.nodes);
+ },
+ error() {
+ createFlash({ message: environmentFetchErrorText });
+ },
+ },
+ projectVariables: {
+ query: getProjectVariables,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ };
+ },
+ update(data) {
+ return data?.project?.ciVariables?.nodes || [];
+ },
+ error() {
+ createFlash({ message: variableFetchErrorText });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return (
+ this.$apollo.queries.projectVariables.loading ||
+ this.$apollo.queries.projectEnvironments.loading
+ );
+ },
+ },
+ methods: {
+ addVariable(variable) {
+ this.variableMutation(ADD_MUTATION_ACTION, variable);
+ },
+ deleteVariable(variable) {
+ this.variableMutation(DELETE_MUTATION_ACTION, variable);
+ },
+ updateVariable(variable) {
+ this.variableMutation(UPDATE_MUTATION_ACTION, variable);
+ },
+ async variableMutation(mutationAction, variable) {
+ try {
+ const currentMutation = this.$options.mutationData[mutationAction];
+ const { data } = await this.$apollo.mutate({
+ mutation: currentMutation.action,
+ variables: {
+ endpoint: this.endpoint,
+ fullPath: this.projectFullPath,
+ projectId: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId),
+ variable,
+ },
+ });
+
+ const { errors } = data[currentMutation.name];
+ if (errors.length > 0) {
+ createFlash({ message: errors[0] });
+ }
+ } catch (e) {
+ createFlash({ message: genericMutationErrorText });
+ }
+ },
+ },
+ mutationData: {
+ [ADD_MUTATION_ACTION]: { action: addProjectVariable, name: 'addProjectVariable' },
+ [UPDATE_MUTATION_ACTION]: { action: updateProjectVariable, name: 'updateProjectVariable' },
+ [DELETE_MUTATION_ACTION]: { action: deleteProjectVariable, name: 'deleteProjectVariable' },
+ },
+};
+</script>
+
+<template>
+ <ci-variable-settings
+ :are-scoped-variables-available="true"
+ :environments="projectEnvironments"
+ :is-loading="isLoading"
+ :variables="projectVariables"
+ @add-variable="addVariable"
+ @delete-variable="deleteVariable"
+ @update-variable="updateVariable"
+ />
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 5ba63de8c96..56c1804910a 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -108,7 +108,6 @@ export default {
return {
newEnvironments: [],
isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
- typeOptions: variableOptions,
validationErrorEventProperty: '',
variable: { ...defaultVariableState, ...this.selectedVariable },
};
@@ -259,6 +258,7 @@ export default {
},
},
defaultScope: allEnvironments.text,
+ variableOptions,
};
</script>
@@ -277,6 +277,7 @@ export default {
v-model="variable.key"
:token-list="$options.tokenList"
:label-text="__('Key')"
+ data-testid="pipeline-form-ci-variable-key"
data-qa-selector="ci_variable_key_field"
/>
@@ -293,21 +294,26 @@ export default {
:state="variableValidationState"
rows="3"
max-rows="6"
+ data-testid="pipeline-form-ci-variable-value"
data-qa-selector="ci_variable_value_field"
class="gl-font-monospace!"
/>
</gl-form-group>
- <div class="d-flex">
- <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5">
+ <div class="gl-display-flex">
+ <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="gl-w-half gl-mr-5">
<gl-form-select
id="ci-variable-type"
v-model="variable.variableType"
- :options="typeOptions"
+ :options="$options.variableOptions"
/>
</gl-form-group>
- <gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope">
+ <gl-form-group
+ label-for="ci-variable-env"
+ class="gl-w-half"
+ data-testid="environment-scope"
+ >
<template #label>
{{ __('Environment scope') }}
<gl-link
@@ -380,7 +386,7 @@ export default {
data-testid="aws-guidance-tip"
@dismiss="dismissTip"
>
- <div class="gl-display-flex gl-flex-direction-row">
+ <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap-wrap gl-md-flex-wrap-nowrap">
<div>
<p>
<gl-sprintf :message="$options.awsTipMessage">
diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue
index cebb7eb85ac..1fbe52388c9 100644
--- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue
@@ -255,6 +255,7 @@ export default {
v-model="key"
:token-list="$options.tokenList"
:label-text="__('Key')"
+ data-testid="pipeline-form-ci-variable-key"
data-qa-selector="ci_variable_key_field"
/>
@@ -271,6 +272,7 @@ export default {
:state="variableValidationState"
rows="3"
max-rows="6"
+ data-testid="pipeline-form-ci-variable-value"
data-qa-selector="ci_variable_value_field"
class="gl-font-monospace!"
/>
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index 5d22974ffbb..e2dd28cdaa1 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -10,7 +10,7 @@ export const displayText = {
};
export const variableTypes = {
- variableType: 'ENV_VAR',
+ envType: 'ENV_VAR',
fileType: 'FILE',
};
@@ -29,13 +29,13 @@ export const allEnvironments = {
export const variableText = {
[types.variableType]: __('Variable'),
[types.fileType]: __('File'),
- [variableTypes.variableType]: __('Variable'),
+ [variableTypes.envType]: __('Variable'),
[variableTypes.fileType]: __('File'),
};
export const variableOptions = [
- { value: types.variableType, text: variableText[types.variableType] },
- { value: types.fileType, text: variableText[types.fileType] },
+ { value: variableTypes.envType, text: variableText[variableTypes.envType] },
+ { value: variableTypes.fileType, text: variableText[variableTypes.fileType] },
];
export const defaultVariableState = {
@@ -44,7 +44,7 @@ export const defaultVariableState = {
masked: false,
protected: false,
value: '',
- variableType: types.variableType,
+ variableType: variableTypes.envType,
};
// eslint-disable-next-line @gitlab/require-i18n-strings
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql
new file mode 100644
index 00000000000..45109762e80
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql
@@ -0,0 +1,3 @@
+mutation addProjectEnvironment($environment: CiEnvironment, $fullPath: ID!) {
+ addProjectEnvironment(environment: $environment, fullPath: $fullPath) @client
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
new file mode 100644
index 00000000000..ab3a46da854
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation addProjectVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $projectId: ID!
+) {
+ addProjectVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ projectId: $projectId
+ ) @client {
+ project {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiProjectVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
new file mode 100644
index 00000000000..e83dc9a5e5e
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation deleteProjectVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $projectId: ID!
+) {
+ deleteProjectVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ projectId: $projectId
+ ) @client {
+ project {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiProjectVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
new file mode 100644
index 00000000000..4788911431b
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation updateProjectVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $projectId: ID!
+) {
+ updateProjectVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ projectId: $projectId
+ ) @client {
+ project {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiProjectVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql
new file mode 100644
index 00000000000..921e0ca25b9
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql
@@ -0,0 +1,11 @@
+query getProjectEnvironments($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ environments {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql
new file mode 100644
index 00000000000..a60a50e4bc4
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql
@@ -0,0 +1,15 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+query getProjectVariables($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js
index be7e3f88cfd..c041531ae30 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js
+++ b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js
@@ -4,9 +4,16 @@ import {
convertObjectPropsToSnakeCase,
} from '../../lib/utils/common_utils';
import { getIdFromGraphQLId } from '../../graphql_shared/utils';
-import { GRAPHQL_GROUP_TYPE, groupString, instanceString } from '../constants';
-import getAdminVariables from './queries/variables.query.graphql';
+import {
+ GRAPHQL_GROUP_TYPE,
+ GRAPHQL_PROJECT_TYPE,
+ groupString,
+ instanceString,
+ projectString,
+} from '../constants';
+import getProjectVariables from './queries/project_variables.query.graphql';
import getGroupVariables from './queries/group_variables.query.graphql';
+import getAdminVariables from './queries/variables.query.graphql';
const prepareVariableForApi = ({ variable, destroy = false }) => {
return {
@@ -28,6 +35,20 @@ const mapVariableTypes = (variables = [], kind) => {
});
};
+const prepareProjectGraphQLResponse = ({ data, projectId, errors = [] }) => {
+ return {
+ errors,
+ project: {
+ __typename: GRAPHQL_PROJECT_TYPE,
+ id: projectId,
+ ciVariables: {
+ __typename: 'CiVariableConnection',
+ nodes: mapVariableTypes(data.variables, projectString),
+ },
+ },
+ };
+};
+
const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => {
return {
errors,
@@ -52,6 +73,28 @@ const prepareAdminGraphQLResponse = ({ data, errors = [] }) => {
};
};
+const callProjectEndpoint = async ({
+ endpoint,
+ fullPath,
+ variable,
+ projectId,
+ cache,
+ destroy = false,
+}) => {
+ try {
+ const { data } = await axios.patch(endpoint, {
+ variables_attributes: [prepareVariableForApi({ variable, destroy })],
+ });
+ return prepareProjectGraphQLResponse({ data, projectId });
+ } catch (e) {
+ return prepareProjectGraphQLResponse({
+ data: cache.readQuery({ query: getProjectVariables, variables: { fullPath } }),
+ projectId,
+ errors: [...e.response.data],
+ });
+ }
+};
+
const callGroupEndpoint = async ({
endpoint,
fullPath,
@@ -91,6 +134,15 @@ const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false })
export const resolvers = {
Mutation: {
+ addProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache });
+ },
+ updateProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache });
+ },
+ deleteProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache, destroy: true });
+ },
addGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
},
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index a74af8aed12..f5bdd4c7b1e 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import CiAdminVariables from './components/ci_admin_variables.vue';
import CiGroupVariables from './components/ci_group_variables.vue';
+import CiProjectVariables from './components/ci_project_variables.vue';
import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue';
import { resolvers } from './graphql/resolvers';
import createStore from './store';
@@ -37,6 +38,8 @@ const mountCiVariableListApp = (containerEl) => {
if (parsedIsGroup) {
component = CiGroupVariables;
+ } else if (parsedIsProject) {
+ component = CiProjectVariables;
}
Vue.use(VueApollo);
@@ -77,7 +80,7 @@ const mountLegacyCiVariableListApp = (containerEl) => {
const {
endpoint,
projectId,
- group,
+ isGroup,
maskableRegex,
protectedByDefault,
awsLogoSvgPath,
@@ -89,13 +92,13 @@ const mountLegacyCiVariableListApp = (containerEl) => {
maskedEnvironmentVariablesLink,
environmentScopeLink,
} = containerEl.dataset;
- const isGroup = parseBoolean(group);
+ const parsedIsGroup = parseBoolean(isGroup);
const isProtectedByDefault = parseBoolean(protectedByDefault);
const store = createStore({
endpoint,
projectId,
- isGroup,
+ isGroup: parsedIsGroup,
maskableRegex,
isProtectedByDefault,
awsLogoSvgPath,
diff --git a/app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue b/app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue
new file mode 100644
index 00000000000..59de6df1e49
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlLink, GlIcon, GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ components: {
+ GlLink,
+ GlIcon,
+ GlBadge,
+ },
+ mixins: [glFeatureFlagMixin()],
+ i18n: {
+ premiumTitle: s__('ClusterAgents|Premium'),
+ },
+ props: {
+ text: {
+ required: true,
+ type: String,
+ },
+ icon: {
+ required: false,
+ type: String,
+ default: 'information',
+ },
+ iconClass: {
+ required: false,
+ type: String,
+ default: 'text-info',
+ },
+ helpUrl: {
+ required: false,
+ type: String,
+ default: null,
+ },
+ featureName: {
+ required: false,
+ type: String,
+ default: null,
+ },
+ },
+ computed: {
+ showPremiumBadge() {
+ return this.featureName && !this.glFeatures[this.featureName];
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="gl-mb-3">
+ <gl-icon :name="icon" :size="16" :class="iconClass" class="gl-mr-2" />
+
+ <gl-link v-if="helpUrl" :href="helpUrl">{{ text }}</gl-link>
+ <span v-else>{{ text }}</span>
+
+ <gl-badge
+ v-if="showPremiumBadge"
+ size="md"
+ class="gl-ml-2 gl-vertical-align-middle"
+ icon="license"
+ variant="tier"
+ >{{ $options.i18n.premiumTitle }}</gl-badge
+ >
+ </li>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/integration_status.vue b/app/assets/javascripts/clusters/agents/components/integration_status.vue
new file mode 100644
index 00000000000..68a77dfbc8e
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/integration_status.vue
@@ -0,0 +1,98 @@
+<script>
+import { GlCollapse, GlButton, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { AGENT_STATUSES } from '~/clusters_list/constants';
+import { getAgentLastContact, getAgentStatus } from '~/clusters_list/clusters_util';
+import {
+ INTEGRATION_STATUS_VALID_TOKEN,
+ INTEGRATION_STATUS_NO_TOKEN,
+ INTEGRATION_STATUS_RESTRICTED_CI_CD,
+} from '../constants';
+import AgentIntegrationStatusRow from './agent_integration_status_row.vue';
+
+export default {
+ components: {
+ GlCollapse,
+ GlButton,
+ GlIcon,
+ AgentIntegrationStatusRow,
+ },
+ i18n: {
+ title: s__('ClusterAgents|Integration Status'),
+ },
+ AGENT_STATUSES,
+ props: {
+ tokens: {
+ required: true,
+ type: Array,
+ },
+ },
+ data() {
+ return {
+ isVisible: false,
+ };
+ },
+ computed: {
+ chevronIcon() {
+ return this.isVisible ? 'chevron-down' : 'chevron-right';
+ },
+ agentStatus() {
+ const lastContact = getAgentLastContact(this.tokens);
+ return getAgentStatus(lastContact);
+ },
+ integrationStatuses() {
+ const statuses = [];
+
+ if (this.agentStatus === 'active') {
+ statuses.push(INTEGRATION_STATUS_VALID_TOKEN);
+ }
+
+ if (!this.tokens.length) {
+ statuses.push(INTEGRATION_STATUS_NO_TOKEN);
+ }
+
+ statuses.push(INTEGRATION_STATUS_RESTRICTED_CI_CD);
+
+ return statuses;
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.isVisible = !this.isVisible;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-button
+ :icon="chevronIcon"
+ variant="link"
+ size="small"
+ class="gl-mr-3"
+ @click="toggleCollapse"
+ >
+ {{ $options.i18n.title }} </gl-button
+ ><span data-testid="agent-status">
+ <gl-icon
+ :name="$options.AGENT_STATUSES[agentStatus].icon"
+ :class="$options.AGENT_STATUSES[agentStatus].class"
+ class="gl-mr-2"
+ />{{ $options.AGENT_STATUSES[agentStatus].name }}
+ </span>
+ <gl-collapse v-model="isVisible" class="gl-ml-5 gl-mt-5">
+ <ul class="gl-list-style-none gl-pl-2 gl-mb-0">
+ <agent-integration-status-row
+ v-for="(status, index) in integrationStatuses"
+ :key="index"
+ :icon="status.icon"
+ :icon-class="status.iconClass"
+ :text="status.text"
+ :help-url="status.helpUrl"
+ :feature-name="status.featureName"
+ />
+ </ul>
+ </gl-collapse>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index e3de8339325..f1bd36b4a63 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -14,6 +14,7 @@ import { MAX_LIST_COUNT, TOKEN_STATUS_ACTIVE } from '../constants';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
import TokenTable from './token_table.vue';
import ActivityEvents from './activity_events_list.vue';
+import IntegrationStatus from './integration_status.vue';
export default {
i18n: {
@@ -51,6 +52,7 @@ export default {
TimeAgoTooltip,
TokenTable,
ActivityEvents,
+ IntegrationStatus,
},
inject: ['agentName', 'projectPath'],
data() {
@@ -105,11 +107,11 @@ export default {
<template>
<section>
- <h2>{{ agentName }}</h2>
+ <h1>{{ agentName }}</h1>
<gl-loading-icon v-if="isLoading && clusterAgent == null" size="lg" class="gl-m-3" />
- <div v-else-if="clusterAgent">
+ <template v-else-if="clusterAgent">
<p data-testid="cluster-agent-create-info">
<gl-sprintf :message="$options.i18n.installedInfo">
<template #name>
@@ -122,7 +124,16 @@ export default {
</gl-sprintf>
</p>
- <gl-tabs sync-active-tab-with-query-params lazy>
+ <integration-status
+ :tokens="tokens"
+ class="gl-py-5 gl-border-t-1 gl-border-t-solid gl-border-t-gray-100"
+ />
+
+ <gl-tabs
+ sync-active-tab-with-query-params
+ lazy
+ class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100"
+ >
<gl-tab :title="$options.i18n.activity" query-param-value="activity">
<activity-events :agent-name="agentName" :project-path="projectPath" />
</gl-tab>
@@ -151,7 +162,7 @@ export default {
</div>
</gl-tab>
</gl-tabs>
- </div>
+ </template>
<gl-alert v-else variant="danger" :dismissible="false">
{{ $options.i18n.loadingError }}
diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue
index f74d66f6b8f..667d10e1753 100644
--- a/app/assets/javascripts/clusters/agents/components/token_table.vue
+++ b/app/assets/javascripts/clusters/agents/components/token_table.vue
@@ -44,36 +44,43 @@ export default {
},
computed: {
fields() {
+ const tdClass = 'gl-vertical-align-middle!';
return [
{
key: 'name',
label: this.$options.i18n.name,
tdAttr: { 'data-testid': 'agent-token-name' },
+ tdClass,
},
{
key: 'lastUsed',
label: this.$options.i18n.lastUsed,
tdAttr: { 'data-testid': 'agent-token-used' },
+ tdClass,
},
{
key: 'createdAt',
label: this.$options.i18n.dateCreated,
tdAttr: { 'data-testid': 'agent-token-created-time' },
+ tdClass,
},
{
key: 'createdBy',
label: this.$options.i18n.createdBy,
tdAttr: { 'data-testid': 'agent-token-created-user' },
+ tdClass,
},
{
key: 'description',
label: this.$options.i18n.description,
tdAttr: { 'data-testid': 'agent-token-description' },
+ tdClass,
},
{
key: 'actions',
label: '',
tdAttr: { 'data-testid': 'agent-token-revoke' },
+ tdClass,
},
];
},
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
index 962fa243903..76af552181f 100644
--- a/app/assets/javascripts/clusters/agents/constants.js
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -1,4 +1,5 @@
import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const MAX_LIST_COUNT = 25;
@@ -46,3 +47,24 @@ export const EVENT_ACTIONS_CLICK = 'click_button';
export const TOKEN_NAME_LIMIT = 255;
export const REVOKE_TOKEN_MODAL_ID = 'revoke-token-%{tokenName}';
+
+export const INTEGRATION_STATUS_VALID_TOKEN = {
+ icon: 'status-success',
+ iconClass: 'text-success-500',
+ text: s__('ClusterAgents|Valid access token'),
+};
+export const INTEGRATION_STATUS_NO_TOKEN = {
+ icon: 'status-alert',
+ iconClass: 'text-danger-500',
+ text: s__('ClusterAgents|No agent access token'),
+};
+
+export const INTEGRATION_STATUS_RESTRICTED_CI_CD = {
+ icon: 'information',
+ iconClass: 'text-info',
+ text: s__('ClusterAgents|CI/CD workflow with restricted access'),
+ helpUrl: helpPagePath('user/clusters/agent/ci_cd_workflow', {
+ anchor: 'restrict-project-and-group-access-by-using-impersonation',
+ }),
+ featureName: 'clusterAgentsCiImpersonation',
+};
diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js
index e2d01723dde..ee36a295513 100644
--- a/app/assets/javascripts/clusters_list/clusters_util.js
+++ b/app/assets/javascripts/clusters_list/clusters_util.js
@@ -1,3 +1,5 @@
+import { ACTIVE_CONNECTION_TIME } from './constants';
+
export function generateAgentRegistrationCommand({ name, token, version, address }) {
return `helm repo add gitlab https://charts.gitlab.io
helm repo update
@@ -12,3 +14,24 @@ helm upgrade --install ${name} gitlab/gitlab-agent \\
export function getAgentConfigPath(clusterAgentName) {
return `.gitlab/agents/${clusterAgentName}`;
}
+
+export function getAgentLastContact(tokens = []) {
+ let lastContact = null;
+ tokens.forEach((token) => {
+ const lastContactToDate = new Date(token.lastUsedAt).getTime();
+ if (lastContactToDate > lastContact) {
+ lastContact = lastContactToDate;
+ }
+ });
+ return lastContact;
+}
+
+export function getAgentStatus(lastContact) {
+ if (lastContact) {
+ const now = new Date().getTime();
+ const diff = now - lastContact;
+
+ return diff >= ACTIVE_CONNECTION_TIME ? 'inactive' : 'active';
+ }
+ return 'unused';
+}
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index 8a4a81d3e96..36f0f8e61ba 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -3,13 +3,9 @@ import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui
import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import {
- MAX_LIST_COUNT,
- ACTIVE_CONNECTION_TIME,
- AGENT_FEEDBACK_ISSUE,
- AGENT_FEEDBACK_KEY,
-} from '../constants';
+import { MAX_LIST_COUNT, AGENT_FEEDBACK_ISSUE, AGENT_FEEDBACK_KEY } from '../constants';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
+import { getAgentLastContact, getAgentStatus } from '../clusters_util';
import AgentEmptyState from './agent_empty_state.vue';
import AgentTable from './agent_table.vue';
@@ -88,8 +84,8 @@ export default {
if (list) {
list = list.map((agent) => {
const configFolder = this.folderList[agent.name];
- const lastContact = this.getLastContact(agent);
- const status = this.getStatus(lastContact);
+ const lastContact = getAgentLastContact(agent?.tokens?.nodes);
+ const status = getAgentStatus(lastContact);
return { ...agent, configFolder, lastContact, status };
});
}
@@ -141,28 +137,6 @@ export default {
});
}
},
- getLastContact(agent) {
- const tokens = agent?.tokens?.nodes;
- let lastContact = null;
- if (tokens?.length) {
- tokens.forEach((token) => {
- const lastContactToDate = new Date(token.lastUsedAt).getTime();
- if (lastContactToDate > lastContact) {
- lastContact = lastContactToDate;
- }
- });
- }
- return lastContact;
- },
- getStatus(lastContact) {
- if (lastContact) {
- const now = new Date().getTime();
- const diff = now - lastContact;
-
- return diff > ACTIVE_CONNECTION_TIME ? 'inactive' : 'active';
- }
- return 'unused';
- },
emitAgentsLoaded() {
const count = this.agents?.project?.clusterAgents?.count;
this.$emit('onAgentsLoad', count);
diff --git a/app/assets/javascripts/code_navigation/utils/dom_utils.js b/app/assets/javascripts/code_navigation/utils/dom_utils.js
index 1a65c1a64a2..90af31b715c 100644
--- a/app/assets/javascripts/code_navigation/utils/dom_utils.js
+++ b/app/assets/javascripts/code_navigation/utils/dom_utils.js
@@ -23,6 +23,7 @@ const wrapTextWithSpan = (el, text) => {
const wrapNodes = (text) => {
const wrapper = createSpan();
+ // eslint-disable-next-line no-unsanitized/property
wrapper.innerHTML = wrapSpacesWithSpans(text);
wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text));
return wrapper.childNodes;
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 95ee3a0d90e..6890d7f6f44 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -293,7 +293,7 @@ export default {
</div>
<gl-modal
- v-if="canRenderPipelineButton"
+ v-if="canRenderPipelineButton || shouldRenderEmptyState"
:id="modalId"
ref="modal"
:modal-id="modalId"
diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
index 6bb654a434f..9cb7cd9607f 100644
--- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
@@ -40,7 +40,7 @@ export default {
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
- :is-check-item="true"
+ is-check-item
:is-checked="project.id === selectedProject.id"
@click="selectProject(project)"
>
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
new file mode 100644
index 00000000000..3891274e35e
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
@@ -0,0 +1,60 @@
+<script>
+import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu';
+
+export default {
+ name: 'BubbleMenu',
+ inject: ['tiptapEditor'],
+ props: {
+ pluginKey: {
+ type: String,
+ required: true,
+ },
+ shouldShow: {
+ type: Function,
+ required: true,
+ },
+ tippyOptions: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ menuVisible: false,
+ };
+ },
+ async mounted() {
+ await this.$nextTick();
+
+ this.tiptapEditor.registerPlugin(
+ BubbleMenuPlugin({
+ pluginKey: this.pluginKey,
+ editor: this.tiptapEditor,
+ element: this.$el,
+ shouldShow: this.shouldShow,
+ tippyOptions: {
+ ...this.tippyOptions,
+ onShow: (...args) => {
+ this.$emit('show', ...args);
+ this.menuVisible = true;
+ },
+ onHidden: (...args) => {
+ this.$emit('hidden', ...args);
+ this.menuVisible = false;
+ },
+ },
+ }),
+ );
+ },
+
+ beforeDestroy() {
+ this.tiptapEditor.unregisterPlugin(this.pluginKey);
+ },
+};
+</script>
+<template>
+ <div>
+ <slot v-if="menuVisible"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue
index 6c0ac8e54d2..a9668ebdb69 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue
@@ -10,13 +10,13 @@ import {
GlSearchBoxByType,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { BubbleMenu } from '@tiptap/vue-2';
import { getParentByTagName } from '~/lib/utils/dom_utils';
import codeBlockLanguageLoader from '../../services/code_block_language_loader';
import CodeBlockHighlight from '../../extensions/code_block_highlight';
import Diagram from '../../extensions/diagram';
import Frontmatter from '../../extensions/frontmatter';
import EditorStateObserver from '../editor_state_observer.vue';
+import BubbleMenu from './bubble_menu.vue';
const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name];
@@ -129,6 +129,10 @@ export default {
deleteCodeBlock() {
this.tiptapEditor.chain().focus().deleteNode(this.codeBlockType).run();
},
+
+ tippyOptions() {
+ return { getReferenceClientRect: this.getReferenceClientRect.bind(this) };
+ },
},
};
</script>
@@ -136,12 +140,9 @@ export default {
<bubble-menu
data-testid="code-block-bubble-menu"
class="gl-shadow gl-rounded-base gl-bg-white"
- :editor="tiptapEditor"
plugin-key="bubbleMenuCodeBlock"
:should-show="shouldShow"
- :tippy-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- getReferenceClientRect,
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :tippy-options="tippyOptions()"
>
<editor-state-observer @transaction="updateCodeBlockInfoToState">
<gl-button-group>
@@ -181,7 +182,7 @@ export default {
</template>
<template v-if="!showCustomLanguageInput" #highlighted-items>
- <gl-dropdown-item :key="selectedLanguage.syntax" is-check-item :is-checked="true">
+ <gl-dropdown-item :key="selectedLanguage.syntax" is-check-item is-checked>
{{ selectedLanguage.label }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
index 05ca7fd75c3..327b0967229 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
@@ -1,6 +1,5 @@
<script>
import { GlButtonGroup } from '@gitlab/ui';
-import { BubbleMenu } from '@tiptap/vue-2';
import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants';
import trackUIControl from '../../services/track_ui_control';
import Paragraph from '../../extensions/paragraph';
@@ -9,6 +8,7 @@ import Audio from '../../extensions/audio';
import Video from '../../extensions/video';
import Image from '../../extensions/image';
import ToolbarButton from '../toolbar_button.vue';
+import BubbleMenu from './bubble_menu.vue';
export default {
components: {
@@ -34,14 +34,17 @@ export default {
);
},
},
+ toggleLinkCommandParams: {
+ href: '',
+ },
};
</script>
<template>
<bubble-menu
data-testid="formatting-bubble-menu"
class="gl-shadow gl-rounded-base gl-bg-white"
- :editor="tiptapEditor"
:should-show="shouldShow"
+ :plugin-key="'formatting'"
>
<gl-button-group>
<toolbar-button
@@ -109,9 +112,7 @@ export default {
content-type="link"
icon-name="link"
editor-command="toggleLink"
- :editor-command-params="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- href: '',
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :editor-command-params="$options.toggleLinkCommandParams"
category="tertiary"
size="medium"
:label="__('Insert link')"
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
index dae0bc63b5a..a4713eb3275 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/link.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
@@ -8,9 +8,9 @@ import {
GlButtonGroup,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { BubbleMenu } from '@tiptap/vue-2';
import Link from '../../extensions/link';
import EditorStateObserver from '../editor_state_observer.vue';
+import BubbleMenu from './bubble_menu.vue';
export default {
components: {
@@ -36,18 +36,9 @@ export default {
isEditing: false,
};
},
- watch: {
- linkCanonicalSrc(value) {
- if (!value) this.isEditing = true;
- },
- },
methods: {
shouldShow() {
- const shouldShow = this.tiptapEditor.isActive(Link.name);
-
- if (!shouldShow) this.isEditing = false;
-
- return shouldShow;
+ return this.tiptapEditor.isActive(Link.name);
},
startEditingLink() {
@@ -92,13 +83,23 @@ export default {
},
updateLinkToState() {
- if (!this.tiptapEditor.isActive(Link.name)) return;
+ const editor = this.tiptapEditor;
+
+ const { href, title, canonicalSrc } = editor.getAttributes(Link.name);
- const { href, title, canonicalSrc } = this.tiptapEditor.getAttributes(Link.name);
+ if (
+ canonicalSrc === this.linkCanonicalSrc &&
+ href === this.linkHref &&
+ title === this.linkTitle
+ ) {
+ return;
+ }
this.linkTitle = title;
this.linkHref = href;
this.linkCanonicalSrc = canonicalSrc || href;
+
+ this.isEditing = !this.linkCanonicalSrc;
},
copyLinkHref() {
@@ -108,6 +109,15 @@ export default {
removeLink() {
this.tiptapEditor.chain().focus().extendMarkRange(Link.name).unsetLink().run();
},
+
+ resetBubbleMenuState() {
+ this.linkTitle = undefined;
+ this.linkHref = undefined;
+ this.linkCanonicalSrc = undefined;
+ },
+ },
+ tippyOptions: {
+ placement: 'bottom',
},
};
</script>
@@ -115,14 +125,13 @@ export default {
<bubble-menu
data-testid="link-bubble-menu"
class="gl-shadow gl-rounded-base gl-bg-white"
- :editor="tiptapEditor"
plugin-key="bubbleMenuLink"
- :should-show="() => shouldShow()"
- :tippy-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- placement: 'bottom',
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :should-show="shouldShow"
+ :tippy-options="$options.tippyOptions"
+ @show="updateLinkToState"
+ @hidden="resetBubbleMenuState"
>
- <editor-state-observer @transaction="updateLinkToState">
+ <editor-state-observer @selectionUpdate="updateLinkToState">
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
<gl-link
v-gl-tooltip
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
index a36a860c440..310bb1be81f 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/media.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
@@ -9,13 +9,13 @@ import {
GlButtonGroup,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { BubbleMenu } from '@tiptap/vue-2';
import { __ } from '~/locale';
import Audio from '../../extensions/audio';
import Image from '../../extensions/image';
import Video from '../../extensions/video';
import EditorStateObserver from '../editor_state_observer.vue';
import { acceptedMimes } from '../../services/upload_helpers';
+import BubbleMenu from './bubble_menu.vue';
const MEDIA_TYPES = [Audio.name, Image.name, Video.name];
@@ -189,9 +189,8 @@ export default {
<bubble-menu
data-testid="media-bubble-menu"
class="gl-shadow gl-rounded-base gl-bg-white"
- :editor="tiptapEditor"
plugin-key="bubbleMenuMedia"
- :should-show="() => shouldShow()"
+ :should-show="shouldShow"
>
<editor-state-observer @transaction="updateMediaInfoToState">
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index c3c881d9135..659c447e861 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,13 +1,16 @@
<script>
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
+import { __ } from '~/locale';
+import { VARIANT_DANGER } from '~/flash';
import { createContentEditor } from '../services/create_content_editor';
+import { ALERT_EVENT } from '../constants';
import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
-import FormattingBubbleMenu from './bubble_menus/formatting.vue';
-import CodeBlockBubbleMenu from './bubble_menus/code_block.vue';
-import LinkBubbleMenu from './bubble_menus/link.vue';
-import MediaBubbleMenu from './bubble_menus/media.vue';
+import FormattingBubbleMenu from './bubble_menus/formatting_bubble_menu.vue';
+import CodeBlockBubbleMenu from './bubble_menus/code_block_bubble_menu.vue';
+import LinkBubbleMenu from './bubble_menus/link_bubble_menu.vue';
+import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
import LoadingIndicator from './loading_indicator.vue';
@@ -43,12 +46,26 @@ export default {
required: false,
default: () => {},
},
+ markdown: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
focused: false,
+ isLoading: false,
+ latestMarkdown: null,
};
},
+ watch: {
+ markdown(markdown) {
+ if (markdown !== this.latestMarkdown) {
+ this.setSerializedContent(markdown);
+ }
+ },
+ },
created() {
const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this;
@@ -61,21 +78,61 @@ export default {
});
},
mounted() {
- this.$emit('initialized', this.contentEditor);
+ this.$emit('initialized');
+ this.setSerializedContent(this.markdown);
},
beforeDestroy() {
this.contentEditor.dispose();
},
methods: {
+ async setSerializedContent(markdown) {
+ this.notifyLoading();
+
+ try {
+ await this.contentEditor.setSerializedContent(markdown);
+ this.contentEditor.setEditable(true);
+ this.notifyLoadingSuccess();
+ this.latestMarkdown = markdown;
+ } catch {
+ this.contentEditor.eventHub.$emit(ALERT_EVENT, {
+ message: __(
+ 'An error occurred while trying to render the content editor. Please try again.',
+ ),
+ variant: VARIANT_DANGER,
+ actionLabel: __('Retry'),
+ action: () => {
+ this.setSerializedContent(markdown);
+ },
+ });
+ this.contentEditor.setEditable(false);
+ this.notifyLoadingError();
+ }
+ },
focus() {
this.focused = true;
},
blur() {
this.focused = false;
},
+ notifyLoading() {
+ this.isLoading = true;
+ this.$emit('loading');
+ },
+ notifyLoadingSuccess() {
+ this.isLoading = false;
+ this.$emit('loadingSuccess');
+ },
+ notifyLoadingError(error) {
+ this.isLoading = false;
+ this.$emit('loadingError', error);
+ },
notifyChange() {
+ this.latestMarkdown = this.contentEditor.getSerializedContent();
+
this.$emit('change', {
empty: this.contentEditor.empty,
+ changed: this.contentEditor.changed,
+ markdown: this.latestMarkdown,
});
},
},
@@ -84,14 +141,7 @@ export default {
<template>
<content-editor-provider :content-editor="contentEditor">
<div>
- <editor-state-observer
- @docUpdate="notifyChange"
- @focus="focus"
- @blur="blur"
- @loading="$emit('loading')"
- @loadingSuccess="$emit('loadingSuccess')"
- @loadingError="$emit('loadingError')"
- />
+ <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
<content-editor-alert />
<div
data-testid="content-editor"
@@ -105,8 +155,12 @@ export default {
<code-block-bubble-menu />
<link-bubble-menu />
<media-bubble-menu />
- <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
- <loading-indicator />
+ <tiptap-editor-content
+ class="md"
+ data-testid="content_editor_editablebox"
+ :editor="contentEditor.tiptapEditor"
+ />
+ <loading-indicator v-if="isLoading" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/content_editor/components/content_editor_alert.vue b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
index c6737da1d77..87eff2451ec 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_alert.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
@@ -14,19 +14,32 @@ export default {
};
},
methods: {
- displayAlert({ message, variant }) {
+ displayAlert({ message, variant, action, actionLabel }) {
this.message = message;
this.variant = variant;
+ this.action = action;
+ this.actionLabel = actionLabel;
},
dismissAlert() {
this.message = null;
},
+ primaryAction() {
+ this.dismissAlert();
+ this.action?.();
+ },
},
};
</script>
<template>
<editor-state-observer @alert="displayAlert">
- <gl-alert v-if="message" class="gl-mb-6" :variant="variant" @dismiss="dismissAlert">
+ <gl-alert
+ v-if="message"
+ class="gl-mb-6"
+ :variant="variant"
+ :primary-button-text="actionLabel"
+ @dismiss="dismissAlert"
+ @primaryAction="primaryAction"
+ >
{{ message }}
</gl-alert>
</editor-state-observer>
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 252f69f7a5d..41c3771bf41 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -1,11 +1,6 @@
<script>
import { debounce } from 'lodash';
-import {
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
- ALERT_EVENT,
-} from '../constants';
+import { ALERT_EVENT } from '../constants';
export const tiptapToComponentMap = {
update: 'docUpdate',
@@ -15,12 +10,7 @@ export const tiptapToComponentMap = {
blur: 'blur',
};
-export const eventHubEvents = [
- ALERT_EVENT,
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
-];
+export const eventHubEvents = [ALERT_EVENT];
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue
index 7bc953e0dc3..e2af6cabddb 100644
--- a/app/assets/javascripts/content_editor/components/loading_indicator.vue
+++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue
@@ -1,40 +1,18 @@
<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
+ data-testid="content-editor-loading-indicator"
+ class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
>
- <div
- v-if="isLoading"
- data-testid="content-editor-loading-indicator"
- 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="lg" />
- </div>
- </editor-state-observer>
+ <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
+ <gl-loading-icon size="lg" />
+ </div>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
index 649e23c29aa..8ed4dfce6de 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
@@ -71,27 +71,31 @@ export default {
};
</script>
<template>
- <gl-dropdown
- v-gl-tooltip
- :aria-label="__('Insert image')"
- :title="__('Insert image')"
- size="small"
- category="tertiary"
- icon="media"
- @hidden="resetFields()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')">
- <template #append>
- <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item @click="openFileUpload">
- {{ __('Upload image') }}
- </gl-dropdown-item>
-
+ <span class="gl-display-inline-flex">
+ <gl-dropdown
+ v-gl-tooltip
+ :text="__('Insert image')"
+ :title="__('Insert image')"
+ size="small"
+ category="tertiary"
+ icon="media"
+ lazy
+ text-sr-only
+ data-testid="insert-image-toolbar-button"
+ @hidden="resetFields()"
+ >
+ <gl-dropdown-form class="gl-px-3!">
+ <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')">
+ <template #append>
+ <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button>
+ </template>
+ </gl-form-input-group>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="openFileUpload">
+ {{ __('Upload image') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
<input
ref="fileSelector"
type="file"
@@ -101,5 +105,5 @@ export default {
data-qa-selector="file_upload_field"
@change="onFileSelect"
/>
- </gl-dropdown>
+ </span>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
index ff525e52873..4fb1e8ce16f 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
@@ -89,31 +89,34 @@ export default {
</script>
<template>
<editor-state-observer @transaction="updateLinkState">
- <gl-dropdown
- v-gl-tooltip
- :aria-label="__('Insert link')"
- :title="__('Insert link')"
- :toggle-class="{ active: isActive }"
- size="small"
- category="tertiary"
- icon="link"
- @show="selectLink()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
- <template #append>
- <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item v-if="isActive" @click="removeLink">
- {{ __('Remove link') }}
- </gl-dropdown-item>
- <gl-dropdown-item v-else @click="openFileUpload">
- {{ __('Upload file') }}
- </gl-dropdown-item>
-
+ <span class="gl-display-inline-flex">
+ <gl-dropdown
+ v-gl-tooltip
+ :title="__('Insert link')"
+ :text="__('Insert link')"
+ :toggle-class="{ active: isActive }"
+ size="small"
+ category="tertiary"
+ icon="link"
+ text-sr-only
+ lazy
+ @show="selectLink()"
+ >
+ <gl-dropdown-form class="gl-px-3!">
+ <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
+ <template #append>
+ <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
+ </template>
+ </gl-form-input-group>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ <gl-dropdown-item v-if="isActive" @click="removeLink">
+ {{ __('Remove link') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-else @click="openFileUpload">
+ {{ __('Upload file') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
<input
ref="fileSelector"
type="file"
@@ -121,6 +124,6 @@ export default {
class="gl-display-none"
@change="onFileSelect"
/>
- </gl-dropdown>
+ </span>
</editor-state-observer>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index 9ad739e7358..6bb122153ef 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -46,7 +46,18 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="plus" class="content-editor-dropdown" right>
+ <gl-dropdown
+ v-gl-tooltip
+ size="small"
+ category="tertiary"
+ icon="plus"
+ :text="__('More')"
+ :title="__('More')"
+ text-sr-only
+ class="content-editor-dropdown"
+ right
+ lazy
+ >
<gl-dropdown-item @click="insert('codeBlock')">
{{ __('Code block') }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
index 18928acef3c..4b1929e1a20 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -1,5 +1,11 @@
<script>
-import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownForm,
+ GlButton,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { clamp } from '../services/utils';
@@ -17,6 +23,9 @@ export default {
GlDropdownDivider,
GlDropdownForm,
},
+ directives: {
+ GlTooltip,
+ },
inject: ['tiptapEditor'],
data() {
return {
@@ -62,7 +71,18 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="table" class="content-editor-dropdown" right>
+ <gl-dropdown
+ v-gl-tooltip
+ size="small"
+ category="tertiary"
+ icon="table"
+ :title="__('Insert table')"
+ :text="__('Insert table')"
+ class="content-editor-dropdown"
+ right
+ text-sr-only
+ lazy
+ >
<gl-dropdown-form class="gl-px-3!">
<div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
<gl-button
diff --git a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
index 13728d4001d..2bf32a70cd1 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
@@ -64,6 +64,7 @@ export default {
data-qa-selector="text_style_dropdown"
:disabled="!activeItem"
:text="activeItemLabel"
+ lazy
>
<gl-dropdown-item
v-for="(item, index) in $options.items"
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index 1030ebbf838..460368b6a11 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -25,7 +25,7 @@ export default {
</script>
<template>
<div
- class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
+ class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-3 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
>
<toolbar-text-style-dropdown
data-testid="text-styles"
diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js
index a39a243ec6b..564cca23afa 100644
--- a/app/assets/javascripts/content_editor/constants/index.js
+++ b/app/assets/javascripts/content_editor/constants/index.js
@@ -58,3 +58,11 @@ export const EXTENSION_PRIORITY_LOWER = 75;
*/
export const EXTENSION_PRIORITY_DEFAULT = 100;
export const EXTENSION_PRIORITY_HIGHEST = 200;
+
+/**
+ * See lib/gitlab/file_type_detection.rb
+ */
+export const SAFE_VIDEO_EXT = ['mp4', 'm4v', 'mov', 'webm', 'ogv'];
+export const SAFE_AUDIO_EXT = ['mp3', 'oga', 'ogg', 'spx', 'wav'];
+
+export const DIAGRAM_LANGUAGES = ['plantuml', 'mermaid'];
diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js
index 9329bbcb2c7..2d4226ccd33 100644
--- a/app/assets/javascripts/content_editor/content_editor.stories.js
+++ b/app/assets/javascripts/content_editor/content_editor.stories.js
@@ -2,7 +2,7 @@ import { ContentEditor } from './index';
export default {
component: ContentEditor,
- title: 'content_editor/components/content_editor',
+ title: 'content_editor/content_editor',
};
const Template = (_, { argTypes }) => ({
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index f87e4d8d1dd..848c4c12a9a 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -3,13 +3,7 @@ import { Plugin, PluginKey } from 'prosemirror-state';
import { __ } from '~/locale';
import { VARIANT_DANGER } from '~/flash';
import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer';
-import {
- ALERT_EVENT,
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
- EXTENSION_PRIORITY_HIGHEST,
-} from '../constants';
+import { ALERT_EVENT, EXTENSION_PRIORITY_HIGHEST } from '../constants';
import CodeBlockHighlight from './code_block_highlight';
import Diagram from './diagram';
import Frontmatter from './frontmatter';
@@ -34,10 +28,8 @@ export default Extension.create({
const { renderMarkdown, eventHub } = options;
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- eventHub.$emit(LOADING_CONTENT_EVENT);
-
deserializer
- .deserialize({ schema: editor.schema, content: markdown })
+ .deserialize({ schema: editor.schema, markdown })
.then(({ document }) => {
if (!document) {
return;
@@ -48,14 +40,12 @@ export default Extension.create({
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;
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index f9de71f601b..54d69d83188 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -1,9 +1,11 @@
import { Extension } from '@tiptap/core';
+import Audio from './audio';
import Blockquote from './blockquote';
import Bold from './bold';
import BulletList from './bullet_list';
import Code from './code';
import CodeBlockHighlight from './code_block_highlight';
+import Diagram from './diagram';
import FootnoteReference from './footnote_reference';
import FootnoteDefinition from './footnote_definition';
import Frontmatter from './frontmatter';
@@ -25,17 +27,21 @@ import Table from './table';
import TableCell from './table_cell';
import TableHeader from './table_header';
import TableRow from './table_row';
+import TableOfContents from './table_of_contents';
+import Video from './video';
export default Extension.create({
addGlobalAttributes() {
return [
{
types: [
+ Audio.name,
Bold.name,
Blockquote.name,
BulletList.name,
Code.name,
CodeBlockHighlight.name,
+ Diagram.name,
FootnoteReference.name,
FootnoteDefinition.name,
Frontmatter.name,
@@ -56,6 +62,8 @@ export default Extension.create({
TableCell.name,
TableHeader.name,
TableRow.name,
+ TableOfContents.name,
+ Video.name,
...HTMLNodes.map((htmlNode) => htmlNode.name),
],
attributes: {
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 75d8581890f..514ab9699bc 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,5 +1,3 @@
-import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
-
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) {
@@ -20,14 +18,19 @@ export class ContentEditor {
}
get changed() {
- return this._pristineDoc?.eq(this.tiptapEditor.state.doc);
+ if (!this._pristineDoc) {
+ return !this.empty;
+ }
+
+ return !this._pristineDoc.eq(this.tiptapEditor.state.doc);
}
get empty() {
- const doc = this.tiptapEditor?.state.doc;
+ return this.tiptapEditor.isEmpty;
+ }
- // Makes sure the document has more than one empty paragraph
- return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0);
+ get editable() {
+ return this.tiptapEditor.isEditable;
}
dispose() {
@@ -55,24 +58,22 @@ export class ContentEditor {
return this._assetResolver.renderDiagram(code, language);
}
+ setEditable(editable = true) {
+ this._tiptapEditor.setOptions({
+ editable,
+ });
+ }
+
async setSerializedContent(serializedContent) {
- const { _tiptapEditor: editor, _eventHub: eventHub } = this;
+ const { _tiptapEditor: editor } = this;
const { doc, tr } = editor.state;
- try {
- eventHub.$emit(LOADING_CONTENT_EVENT);
- const { document } = await this.deserialize(serializedContent);
-
- if (document) {
- this._pristineDoc = document;
- tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true);
- editor.view.dispatch(tr);
- }
+ const { document } = await this.deserialize(serializedContent);
- eventHub.$emit(LOADING_SUCCESS_EVENT);
- } catch (e) {
- eventHub.$emit(LOADING_ERROR_EVENT, e);
- throw e;
+ if (document) {
+ this._pristineDoc = document;
+ tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true);
+ editor.view.dispatch(tr);
}
}
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 7a289df94ea..5ed7f3dc23d 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -127,7 +127,7 @@ export const createContentEditor = ({
MathInline,
OrderedList,
Paragraph,
- PasteMarkdown,
+ PasteMarkdown.configure({ eventHub, renderMarkdown }),
Reference,
ReferenceDefinition,
Sourcemap,
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 472a0a4815b..ba0cad6c91c 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -108,7 +108,10 @@ const defaultSerializerConfig = {
},
nodes: {
- [Audio.name]: renderPlayable,
+ [Audio.name]: preserveUnchanged({
+ render: renderPlayable,
+ inline: true,
+ }),
[Blockquote.name]: preserveUnchanged((state, node) => {
if (node.attrs.multiline) {
state.write('>>>');
@@ -123,7 +126,7 @@ const defaultSerializerConfig = {
}),
[BulletList.name]: preserveUnchanged(renderBulletList),
[CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock),
- [Diagram.name]: renderCodeBlock,
+ [Diagram.name]: preserveUnchanged(renderCodeBlock),
[DescriptionList.name]: renderHTMLNode('dl', true),
[DescriptionItem.name]: (state, node, parent, index) => {
if (index === 1) state.ensureNewLine();
@@ -203,10 +206,10 @@ const defaultSerializerConfig = {
},
overwriteSourcePreservationStrategy: true,
}),
- [TableOfContents.name]: (state, node) => {
+ [TableOfContents.name]: preserveUnchanged((state, node) => {
state.write('[[_TOC_]]');
state.closeBlock(node);
- },
+ }),
[Table.name]: preserveUnchanged(renderTable),
[TableCell.name]: renderTableCell,
[TableHeader.name]: renderTableCell,
@@ -220,7 +223,10 @@ const defaultSerializerConfig = {
else renderBulletList(state, node);
}),
[Text.name]: defaultMarkdownSerializer.nodes.text,
- [Video.name]: renderPlayable,
+ [Video.name]: preserveUnchanged({
+ render: renderPlayable,
+ inline: true,
+ }),
[WordBreak.name]: (state) => state.write('<wbr>'),
...HTMLNodes.reduce((serializers, htmlNode) => {
return {
diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
index 8a15633708f..ca290efca11 100644
--- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
@@ -1,7 +1,10 @@
import { render } from '~/lib/gfm';
import { isValidAttribute } from '~/lib/dompurify';
+import { SAFE_AUDIO_EXT, SAFE_VIDEO_EXT, DIAGRAM_LANGUAGES } from '../constants';
import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter';
+const ALL_AUDIO_VIDEO_EXT = [...SAFE_AUDIO_EXT, ...SAFE_VIDEO_EXT];
+
const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del'];
const isTaskItem = (hastNode) => {
@@ -17,6 +20,32 @@ const getTableCellAttrs = (hastNode) => ({
rowspan: parseInt(hastNode.properties.rowSpan, 10) || 1,
});
+const getMediaAttrs = (hastNode) => ({
+ src: hastNode.properties.src,
+ canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src,
+ isReference: hastNode.properties.isReference === 'true',
+ title: hastNode.properties.title,
+ alt: hastNode.properties.alt,
+});
+
+const isMediaTag = (hastNode) => hastNode.tagName === 'img' && Boolean(hastNode.properties);
+
+const extractMediaFileExtension = (url) => {
+ try {
+ const parsedUrl = new URL(url, window.location.origin);
+
+ return /\.(\w+)$/.exec(parsedUrl.pathname)?.[1] ?? null;
+ } catch {
+ return null;
+ }
+};
+
+const isCodeBlock = (hastNode) => hastNode.tagName === 'codeblock';
+
+const isDiagramCodeBlock = (hastNode) => DIAGRAM_LANGUAGES.includes(hastNode.properties?.language);
+
+const getCodeBlockAttrs = (hastNode) => ({ language: hastNode.properties.language });
+
const factorySpecs = {
blockquote: { type: 'block', selector: 'blockquote' },
paragraph: { type: 'block', selector: 'p' },
@@ -45,8 +74,13 @@ const factorySpecs = {
},
codeBlock: {
type: 'block',
- selector: 'codeblock',
- getAttrs: (hastNode) => ({ ...hastNode.properties }),
+ selector: (hastNode) => isCodeBlock(hastNode) && !isDiagramCodeBlock(hastNode),
+ getAttrs: getCodeBlockAttrs,
+ },
+ diagram: {
+ type: 'block',
+ selector: (hastNode) => isCodeBlock(hastNode) && isDiagramCodeBlock(hastNode),
+ getAttrs: getCodeBlockAttrs,
},
horizontalRule: {
type: 'block',
@@ -121,16 +155,26 @@ const factorySpecs = {
selector: 'pre',
wrapInParagraph: true,
},
+ audio: {
+ type: 'inline',
+ selector: (hastNode) =>
+ isMediaTag(hastNode) &&
+ SAFE_AUDIO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)),
+ getAttrs: getMediaAttrs,
+ },
image: {
type: 'inline',
- selector: 'img',
- getAttrs: (hastNode) => ({
- src: hastNode.properties.src,
- canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src,
- isReference: hastNode.properties.isReference === 'true',
- title: hastNode.properties.title,
- alt: hastNode.properties.alt,
- }),
+ selector: (hastNode) =>
+ isMediaTag(hastNode) &&
+ !ALL_AUDIO_VIDEO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)),
+ getAttrs: getMediaAttrs,
+ },
+ video: {
+ type: 'inline',
+ selector: (hastNode) =>
+ isMediaTag(hastNode) &&
+ SAFE_VIDEO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)),
+ getAttrs: getMediaAttrs,
},
hardBreak: {
type: 'inline',
@@ -193,6 +237,11 @@ const factorySpecs = {
language: hastNode.properties.language,
}),
},
+
+ tableOfContents: {
+ type: 'block',
+ selector: 'tableofcontents',
+ },
};
const SANITIZE_ALLOWLIST = ['level', 'identifier', 'numeric', 'language', 'url', 'isReference'];
@@ -250,6 +299,7 @@ export default () => {
'yaml',
'toml',
'json',
+ 'tableOfContents',
],
});
diff --git a/app/assets/javascripts/crm/constants.js b/app/assets/javascripts/crm/constants.js
index 815289e075e..832efa90956 100644
--- a/app/assets/javascripts/crm/constants.js
+++ b/app/assets/javascripts/crm/constants.js
@@ -5,3 +5,7 @@ export const trackViewsOptions = {
category: 'Customer Relations' /* eslint-disable-line @gitlab/require-i18n-strings */,
action: 'view_contacts_list',
};
+export const organizationTrackViewsOptions = {
+ category: 'Customer Relations' /* eslint-disable-line @gitlab/require-i18n-strings */,
+ action: 'view_organizations_list',
+};
diff --git a/app/assets/javascripts/crm/organizations/bundle.js b/app/assets/javascripts/crm/organizations/bundle.js
index 828d7cd426c..5897810a384 100644
--- a/app/assets/javascripts/crm/organizations/bundle.js
+++ b/app/assets/javascripts/crm/organizations/bundle.js
@@ -3,6 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import CrmOrganizationsRoot from './components/organizations_root.vue';
import routes from './routes';
@@ -21,7 +22,14 @@ export default () => {
return false;
}
- const { basePath, canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath } = el.dataset;
+ const {
+ basePath,
+ canAdminCrmOrganization,
+ groupFullPath,
+ groupId,
+ groupIssuesPath,
+ textQuery,
+ } = el.dataset;
const router = new VueRouter({
base: basePath,
@@ -33,7 +41,13 @@ export default () => {
el,
router,
apolloProvider,
- provide: { canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath },
+ provide: {
+ canAdminCrmOrganization: parseBoolean(canAdminCrmOrganization),
+ groupFullPath,
+ groupId,
+ groupIssuesPath,
+ textQuery,
+ },
render(createElement) {
return createElement(CrmOrganizationsRoot);
},
diff --git a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
index 97b75091cac..1bdcd9ba352 100644
--- a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
+++ b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
@@ -1,12 +1,37 @@
#import "./crm_organization_fields.fragment.graphql"
-query organizations($groupFullPath: ID!) {
+query organizations(
+ $groupFullPath: ID!
+ $state: CustomerRelationsOrganizationState
+ $searchTerm: String
+ $sort: OrganizationSort
+ $firstPageSize: Int
+ $lastPageSize: Int
+ $prevPageCursor: String = ""
+ $nextPageCursor: String = ""
+ $ids: [CustomerRelationsOrganizationID!]
+) {
group(fullPath: $groupFullPath) {
id
- organizations {
+ organizations(
+ state: $state
+ search: $searchTerm
+ sort: $sort
+ first: $firstPageSize
+ last: $lastPageSize
+ after: $nextPageCursor
+ before: $prevPageCursor
+ ids: $ids
+ ) {
nodes {
...OrganizationFragment
}
+ pageInfo {
+ hasNextPage
+ endCursor
+ hasPreviousPage
+ startCursor
+ }
}
}
}
diff --git a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql
new file mode 100644
index 00000000000..fb6064e171f
--- /dev/null
+++ b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql
@@ -0,0 +1,11 @@
+query organizationsCountByState($groupFullPath: ID!, $searchTerm: String) {
+ group(fullPath: $groupFullPath) {
+ __typename
+ id
+ organizationStateCounts(search: $searchTerm) {
+ all
+ active
+ inactive
+ }
+ }
+}
diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
index 5fd0294b0ea..32900d45f22 100644
--- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
+++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
@@ -36,7 +36,7 @@ export default {
getQuery() {
return {
query: getGroupOrganizationsQuery,
- variables: { groupFullPath: this.groupFullPath },
+ variables: { groupFullPath: this.groupFullPath, ids: [this.organizationGraphQLId] },
};
},
title() {
diff --git a/app/assets/javascripts/crm/organizations/components/organizations_root.vue b/app/assets/javascripts/crm/organizations/components/organizations_root.vue
index a165dd68603..155c8f00537 100644
--- a/app/assets/javascripts/crm/organizations/components/organizations_root.vue
+++ b/app/assets/javascripts/crm/organizations/components/organizations_root.vue
@@ -1,36 +1,54 @@
<script>
-import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants';
+import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
+import {
+ bodyTrClass,
+ initialPaginationState,
+} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
+import { convertToSnakeCase } from '~/lib/utils/text_utility';
+import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME, organizationTrackViewsOptions } from '../../constants';
import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql';
+import getGroupOrganizationsCountByStateQuery from './graphql/get_group_organizations_count_by_state.query.graphql';
export default {
components: {
- GlAlert,
GlButton,
GlLoadingIcon,
GlTable,
+ PaginatedTableWithSearchAndTabs,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'],
+ inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath', 'textQuery'],
data() {
return {
- organizations: [],
+ organizations: { list: [] },
+ organizationsCount: {},
error: false,
+ filteredByStatus: '',
+ pagination: initialPaginationState,
+ statusFilter: 'all',
+ searchTerm: this.textQuery,
+ sort: 'NAME_ASC',
+ sortDesc: false,
};
},
apollo: {
organizations: {
- query() {
- return getGroupOrganizationsQuery;
- },
+ query: getGroupOrganizationsQuery,
variables() {
return {
groupFullPath: this.groupFullPath,
+ searchTerm: this.searchTerm,
+ state: this.statusFilter,
+ sort: this.sort,
+ firstPageSize: this.pagination.firstPageSize,
+ lastPageSize: this.pagination.lastPageSize,
+ prevPageCursor: this.pagination.prevPageCursor,
+ nextPageCursor: this.pagination.nextPageCursor,
};
},
update(data) {
@@ -40,19 +58,52 @@ export default {
this.error = true;
},
},
+ organizationsCount: {
+ query: getGroupOrganizationsCountByStateQuery,
+ variables() {
+ return {
+ groupFullPath: this.groupFullPath,
+ searchTerm: this.searchTerm,
+ };
+ },
+ update(data) {
+ return data?.group?.organizationStateCounts;
+ },
+ error() {
+ this.error = true;
+ },
+ },
},
computed: {
isLoading() {
return this.$apollo.queries.organizations.loading;
},
- canAdmin() {
- return parseBoolean(this.canAdminCrmOrganization);
+ tbodyTrClass() {
+ return {
+ [bodyTrClass]: !this.loading && !this.isEmpty,
+ };
},
},
methods: {
+ errorAlertDismissed() {
+ this.error = false;
+ },
extractOrganizations(data) {
const organizations = data?.group?.organizations?.nodes || [];
- return organizations.slice().sort((a, b) => a.name.localeCompare(b.name));
+ const pageInfo = data?.group?.organizations?.pageInfo || {};
+ return {
+ list: organizations,
+ pageInfo,
+ };
+ },
+ fetchSortedData({ sortBy, sortDesc }) {
+ const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
+ const sortingDirection = sortDesc ? 'DESC' : 'ASC';
+ this.pagination = initialPaginationState;
+ this.sort = `${sortingColumn}_${sortingDirection}`;
+ },
+ filtersChanged({ searchTerm }) {
+ this.searchTerm = searchTerm;
},
getIssuesPath(path, value) {
return `${path}?crm_organization_id=${value}`;
@@ -60,6 +111,13 @@ export default {
getEditRoute(id) {
return { name: this.$options.EDIT_ROUTE_NAME, params: { id } };
},
+ pageChanged(pagination) {
+ this.pagination = pagination;
+ },
+ statusChanged({ filters, status }) {
+ this.statusFilter = filters;
+ this.filteredByStatus = status;
+ },
},
fields: [
{ key: 'name', sortable: true },
@@ -83,60 +141,113 @@ export default {
},
EDIT_ROUTE_NAME,
NEW_ROUTE_NAME,
+ statusTabs: [
+ {
+ title: __('Active'),
+ status: 'ACTIVE',
+ filters: 'active',
+ },
+ {
+ title: __('Inactive'),
+ status: 'INACTIVE',
+ filters: 'inactive',
+ },
+ {
+ title: __('All'),
+ status: 'ALL',
+ filters: 'all',
+ },
+ ],
+ organizationTrackViewsOptions,
+ emptyArray: [],
};
</script>
<template>
<div>
- <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false">
- {{ $options.i18n.errorText }}
- </gl-alert>
- <div
- class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6"
+ <paginated-table-with-search-and-tabs
+ :show-items="true"
+ :show-error-msg="false"
+ :i18n="$options.i18n"
+ :items="organizations.list"
+ :page-info="organizations.pageInfo"
+ :items-count="organizationsCount"
+ :status-tabs="$options.statusTabs"
+ :track-views-options="$options.organizationTrackViewsOptions"
+ :filter-search-tokens="$options.emptyArray"
+ filter-search-key="organizations"
+ @page-changed="pageChanged"
+ @tabs-changed="statusChanged"
+ @filters-changed="filtersChanged"
+ @error-alert-dismissed="errorAlertDismissed"
>
- <h2 class="gl-font-size-h2 gl-my-0">
- {{ $options.i18n.title }}
- </h2>
- <div
- v-if="canAdmin"
- class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"
- >
- <router-link :to="{ name: $options.NEW_ROUTE_NAME }">
- <gl-button variant="confirm" data-testid="new-organization-button">
+ <template #header-actions>
+ <router-link v-if="canAdminCrmOrganization" :to="{ name: $options.NEW_ROUTE_NAME }">
+ <gl-button
+ class="gl-my-3 gl-mr-5"
+ variant="confirm"
+ data-testid="new-organization-button"
+ >
{{ $options.i18n.newOrganization }}
</gl-button>
</router-link>
- </div>
- </div>
- <router-view />
- <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
- <gl-table
- v-else
- class="gl-mt-5"
- :items="organizations"
- :fields="$options.fields"
- :empty-text="$options.i18n.emptyText"
- show-empty
- >
- <template #cell(id)="{ value: id }">
- <gl-button
- v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
- class="gl-mr-3"
- data-testid="issues-link"
- icon="issues"
- :aria-label="$options.i18n.issuesButtonLabel"
- :href="getIssuesPath(groupIssuesPath, id)"
- />
- <router-link :to="getEditRoute(id)">
- <gl-button
- v-if="canAdmin"
- v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
- data-testid="edit-organization-button"
- icon="pencil"
- :aria-label="$options.i18n.editButtonLabel"
- />
- </router-link>
</template>
- </gl-table>
+
+ <template #title>
+ {{ $options.i18n.title }}
+ </template>
+
+ <template #table>
+ <gl-table
+ :items="organizations.list"
+ :fields="$options.fields"
+ :busy="isLoading"
+ stacked="md"
+ :tbody-tr-class="tbodyTrClass"
+ sort-direction="asc"
+ :sort-desc.sync="sortDesc"
+ sort-by="createdAt"
+ show-empty
+ no-local-sorting
+ sort-icon-left
+ fixed
+ @sort-changed="fetchSortedData"
+ >
+ <template #cell(id)="{ value: id }">
+ <gl-button
+ v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
+ class="gl-mr-3"
+ data-testid="issues-link"
+ icon="issues"
+ :aria-label="$options.i18n.issuesButtonLabel"
+ :href="getIssuesPath(groupIssuesPath, id)"
+ />
+ <router-link :to="getEditRoute(id)">
+ <gl-button
+ v-if="canAdminCrmOrganization"
+ v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
+ data-testid="edit-organization-button"
+ icon="pencil"
+ :aria-label="$options.i18n.editButtonLabel"
+ />
+ </router-link>
+ </template>
+
+ <template #table-busy>
+ <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ </template>
+
+ <template #empty>
+ <span v-if="error">
+ {{ $options.i18n.errorText }}
+ </span>
+ <span v-else>
+ {{ $options.i18n.emptyText }}
+ </span>
+ </template>
+ </gl-table>
+ </template>
+ </paginated-table-with-search-and-tabs>
+ <router-view />
</div>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
index 85a40b89b77..f1fdffd4b72 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -246,9 +246,7 @@ export default {
</p>
<p class="gl-m-0">
<span data-testid="vsa-stage-event-build-author-and-date">
- <gl-link class="gl-text-black-normal build-date" :href="item.url">{{
- item.date
- }}</gl-link>
+ <gl-link class="gl-text-black-normal" :href="item.url">{{ item.date }}</gl-link>
{{ s__('ByAuthor|by') }}
<gl-link
class="gl-text-black-normal issue-author-link"
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time.vue b/app/assets/javascripts/cycle_analytics/components/total_time.vue
index a5a90a56974..725952c3518 100644
--- a/app/assets/javascripts/cycle_analytics/components/total_time.vue
+++ b/app/assets/javascripts/cycle_analytics/components/total_time.vue
@@ -52,7 +52,7 @@ export default {
};
</script>
<template>
- <span class="total-time">
+ <span>
<template v-if="hasData">
{{ calculatedTime.duration }} <span>{{ calculatedTime.units }}</span>
</template>
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 f686cd0db95..17decb6b448 100644
--- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
+++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
@@ -57,6 +57,10 @@ export default {
includeSubgroups: true,
};
},
+ currentDate() {
+ const now = new Date();
+ return new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
+ },
},
multiProjectSelect: true,
maxDateRange: DATE_RANGE_LIMIT,
@@ -93,6 +97,7 @@ export default {
v-if="hasDateRangeFilter"
:start-date="startDate"
:end-date="endDate"
+ :max-date="currentDate"
:max-date-range="$options.maxDateRange"
:include-selected-date="true"
class="js-daterange-picker"
diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js
index 6fe353405d4..83068cabf0f 100644
--- a/app/assets/javascripts/cycle_analytics/store/getters.js
+++ b/app/assets/javascripts/cycle_analytics/store/getters.js
@@ -1,5 +1,5 @@
-import dateFormat from 'dateformat';
import { dateFormats } from '~/analytics/shared/constants';
+import dateFormat from '~/lib/dateformat';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { PAGINATION_TYPE } from '../constants';
import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils';
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index d811bb3b0bf..c9097b9384f 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -4,11 +4,11 @@ import { head, tail } from 'lodash';
import { s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import actionBtn from './action_btn.vue';
+import ActionBtn from './action_btn.vue';
export default {
components: {
- actionBtn,
+ ActionBtn,
GlButton,
GlIcon,
GlLink,
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index d71f4f5507f..77ec1ef590f 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -1,9 +1,9 @@
<script>
-import deployKey from './key.vue';
+import DeployKey from './key.vue';
export default {
components: {
- deployKey,
+ DeployKey,
},
props: {
keys: {
diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js
index 6e439be42ae..83601d5b2e3 100644
--- a/app/assets/javascripts/deploy_keys/index.js
+++ b/app/assets/javascripts/deploy_keys/index.js
@@ -1,11 +1,11 @@
import Vue from 'vue';
-import deployKeysApp from './components/app.vue';
+import DeployKeysApp from './components/app.vue';
export default () =>
new Vue({
el: document.getElementById('js-deploy-keys'),
components: {
- deployKeysApp,
+ DeployKeysApp,
},
data() {
return {
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/render.js b/app/assets/javascripts/deprecated_jquery_dropdown/render.js
index f10c2d82b61..0f612989bb4 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/render.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/render.js
@@ -13,6 +13,7 @@ const renderersByType = {
},
header(element, data) {
element.classList.add('dropdown-header');
+ // eslint-disable-next-line no-unsanitized/property
element.innerHTML = data.content;
return element;
@@ -122,6 +123,7 @@ function assignTextToLink(el, data, options) {
const text = getLinkText(data, options);
if (options.icon || options.highlight) {
+ // eslint-disable-next-line no-unsanitized/property
el.innerHTML = text;
} else {
el.textContent = text;
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index ac00af2ab34..124780df8a5 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -174,6 +174,7 @@ export default {
this.$emit('open-form', this.discussion.id);
this.isFormRendered = true;
},
+
toggleResolvedStatus() {
this.isResolving = true;
@@ -234,6 +235,7 @@ export default {
:note="firstNote"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
+ :noteable-id="noteableId"
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('update-note-error', $event)"
>
@@ -276,6 +278,7 @@ export default {
:note="note"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
+ :noteable-id="noteableId"
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('update-note-error', $event)"
/>
@@ -307,6 +310,8 @@ export default {
v-model="discussionComment"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
+ :noteable-id="noteableId"
+ :discussion-id="discussion.id"
@submit-form="mutate"
@cancel-form="hideForm"
>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index 5fb5989e11a..e629f74ba02 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -45,6 +45,10 @@ export default {
required: false,
default: '',
},
+ noteableId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -160,6 +164,7 @@ export default {
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
:is-new-comment="false"
+ :noteable-id="noteableId"
class="gl-mt-5"
@submit-form="mutate"
@cancel-form="hideForm"
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 1b6458668f5..4faeba3983b 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -1,7 +1,11 @@
<script>
import { GlButton, GlModal } from '@gitlab/ui';
+import $ from 'jquery';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
+import Autosave from '~/autosave';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
export default {
@@ -30,10 +34,20 @@ export default {
required: false,
default: true,
},
+ noteableId: {
+ type: String,
+ required: true,
+ },
+ discussionId: {
+ type: String,
+ required: false,
+ default: 'new',
+ },
},
data() {
return {
formText: this.value,
+ isLoggedIn: isLoggedIn(),
};
},
computed: {
@@ -64,13 +78,19 @@ export default {
markdownDocsPath() {
return helpPagePath('user/markdown');
},
+ shortDiscussionId() {
+ return isGid(this.discussionId) ? getIdFromGraphQLId(this.discussionId) : this.discussionId;
+ },
},
mounted() {
this.focusInput();
},
methods: {
submitForm() {
- if (this.hasValue) this.$emit('submit-form');
+ if (this.hasValue) {
+ this.$emit('submit-form');
+ this.autosaveDiscussion.reset();
+ }
},
cancelComment() {
if (this.hasValue && this.formText !== this.value) {
@@ -79,8 +99,22 @@ export default {
this.$emit('cancel-form');
}
},
+ confirmCancelCommentModal() {
+ this.$emit('cancel-form');
+ this.autosaveDiscussion.reset();
+ },
focusInput() {
this.$refs.textarea.focus();
+ this.initAutosaveComment();
+ },
+ initAutosaveComment() {
+ if (this.isLoggedIn) {
+ this.autosaveDiscussion = new Autosave($(this.$refs.textarea), [
+ s__('DesignManagement|Discussion'),
+ getIdFromGraphQLId(this.noteableId),
+ this.shortDiscussionId,
+ ]);
+ }
},
},
};
@@ -124,7 +158,7 @@ export default {
type="submit"
data-track-action="click_button"
data-qa-selector="save_comment_button"
- @click="$emit('submit-form')"
+ @click="submitForm"
>
{{ buttonText }}
</gl-button>
@@ -144,7 +178,7 @@ export default {
:ok-title="modalSettings.okTitle"
:cancel-title="modalSettings.cancelTitle"
modal-id="cancel-comment-modal"
- @ok="$emit('cancel-form')"
+ @ok="confirmCancelCommentModal"
>{{ modalSettings.content }}
</gl-modal>
</form>
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 3092b8554ac..1e36aa686a4 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -128,7 +128,7 @@ export default {
params: { id: filename },
query: $route.query,
}"
- class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new"
+ class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0"
>
<div
class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative"
diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue
index 816d7ac7abf..f10545faea6 100644
--- a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue
+++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue
@@ -73,8 +73,8 @@ export default {
<gl-dropdown-item
v-for="(version, index) in allVersions"
:key="version.id"
- :is-check-item="true"
- :is-check-centered="true"
+ is-check-item
+ is-check-centered
:is-checked="findVersionId(version.id) === currentVersionId"
:avatar-url="getAvatarUrl(version)"
@click="routeToVersion(version.id)"
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 1825ce7f092..228ad637b9e 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -418,6 +418,7 @@ export default {
v-model="comment"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
+ :noteable-id="design.id"
@submit-form="mutate"
@cancel-form="closeCommentForm"
/> </apollo-mutation
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 91e35ad3764..07f7a19f7d4 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -135,7 +135,7 @@ export default {
designDropzoneWrapperClass() {
return this.isDesignListEmpty
? 'col-12'
- : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3';
+ : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mt-5';
},
},
mounted() {
@@ -364,15 +364,15 @@ export default {
data-testid="design-toolbar-wrapper"
>
<div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full gl-flex-wrap"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full gl-flex-wrap gl-gap-3"
>
- <div class="gl-display-flex gl-align-items-center gl-my-2">
+ <div class="gl-display-flex gl-align-items-center">
<span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span>
<design-version-dropdown />
</div>
<div
v-show="hasDesigns"
- class="gl-display-flex gl-align-items-center gl-my-2"
+ class="gl-display-flex gl-align-items-center"
data-testid="design-selector-toolbar"
>
<gl-button
@@ -413,7 +413,7 @@ export default {
</div>
</div>
</header>
- <div class="gl-mt-6">
+ <div>
<gl-loading-icon v-if="isLoading" size="lg" />
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ __('An error occurred while loading designs. Please try again.') }}
@@ -449,7 +449,7 @@ export default {
<li
v-for="design in designs"
:key="design.id"
- class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
+ class="col-md-6 col-lg-3 gl-mt-5 gl-bg-transparent gl-shadow-none js-design-tile"
>
<design-dropzone
:display-as-card="hasDesigns"
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 530f3a3a7f7..f5c0776ca35 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -331,6 +331,8 @@ export default {
mrReviews: this.rehydratedMrReviews,
});
+ this.interfaceWithDOM();
+
if (this.endpointCodequality) {
this.setCodequalityEndpoint(this.endpointCodequality);
}
@@ -445,6 +447,16 @@ export default {
notesEventHub.$off('refetchDiffData', this.refetchDiffData);
notesEventHub.$off('fetchDiffData', this.fetchData);
},
+ interfaceWithDOM() {
+ this.diffsTab = document.querySelector('.js-diffs-tab');
+ },
+ updateChangesTabCount() {
+ const badge = this.diffsTab.querySelector('.gl-badge');
+
+ if (this.diffsTab && badge) {
+ badge.textContent = this.diffFilesLength;
+ }
+ },
navigateToDiffFileNumber(number) {
this.navigateToDiffFileIndex(number - 1);
},
@@ -461,7 +473,11 @@ export default {
this.fetchDiffFilesMeta()
.then(({ real_size }) => {
this.diffFilesLength = parseInt(real_size, 10);
- if (toggleTree) this.setTreeDisplay();
+ if (toggleTree) {
+ this.setTreeDisplay();
+ }
+
+ this.updateChangesTabCount();
})
.catch(() => {
createFlash({
@@ -641,6 +657,7 @@ export default {
<div
v-if="renderFileTree"
:style="{ width: `${treeWidth}px` }"
+ :class="{ 'is-sidebar-moved': glFeatures.movedMrSidebar }"
class="diff-tree-list js-diff-tree-list gl-px-5"
>
<panel-resizer
diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
index fd219a7d00f..4501988ee4f 100644
--- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
+++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
@@ -37,7 +37,7 @@ export default {
:class="{
'is-active': version.selected,
}"
- :is-check-item="true"
+ is-check-item
:is-checked="version.selected"
:href="version.href"
>
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue
index f339b108a11..8498724740f 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality.vue
+++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue
@@ -5,10 +5,6 @@ import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/c
export default {
components: { GlButton, GlIcon },
props: {
- line: {
- type: Number,
- required: true,
- },
codeQuality: {
type: Array,
required: true,
@@ -33,7 +29,7 @@ export default {
<li
v-for="finding in codeQuality"
:key="finding.description"
- class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100"
+ class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100 gl-font-regular"
>
<gl-icon
:size="12"
@@ -50,7 +46,7 @@ export default {
size="small"
icon="close"
class="gl-absolute gl-right-2 gl-top-2"
- @click="$emit('hideCodeQualityFindings', line)"
+ @click="$emit('hideCodeQualityFindings')"
/>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 70071a3ff53..d7b63d205dc 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -11,7 +11,7 @@ import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_prev
import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue';
import NoteForm from '~/notes/components/note_form.vue';
import eventHub from '~/notes/event_hub';
-import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { IMAGE_DIFF_POSITION_TYPE } from '../constants';
import { getDiffMode } from '../store/utils';
import DiffDiscussions from './diff_discussions.vue';
@@ -28,7 +28,7 @@ export default {
ImageDiffOverlay,
NotDiffableViewer,
NoPreviewViewer,
- userAvatarLink,
+ UserAvatarLink,
DiffFileDrafts,
},
mixins: [diffLineNoteFormMixin, draftCommentsMixin],
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index b39b50c4cdc..25d3bda147b 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -2,11 +2,11 @@
import { GlIcon } from '@gitlab/ui';
import { mapActions } from 'vuex';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
-import noteableDiscussion from '~/notes/components/noteable_discussion.vue';
+import NoteableDiscussion from '~/notes/components/noteable_discussion.vue';
export default {
components: {
- noteableDiscussion,
+ NoteableDiscussion,
GlIcon,
DesignNotePin,
},
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 07316f9433a..705b43a222d 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -19,6 +19,7 @@ import { scrollToElement } from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DIFF_FILE_AUTOMATIC_COLLAPSE } from '../constants';
import { DIFF_FILE_HEADER } from '../i18n';
@@ -45,7 +46,7 @@ export default {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
- mixins: [IdState({ idProp: (vm) => vm.diffFile.file_hash })],
+ mixins: [IdState({ idProp: (vm) => vm.diffFile.file_hash }), glFeatureFlagsMixin()],
i18n: {
...DIFF_FILE_HEADER,
compareButtonLabel: __('Compare submodule commit revisions'),
@@ -276,7 +277,10 @@ export default {
<template>
<div
ref="header"
- :class="{ 'gl-z-dropdown-menu!': idState.moreActionsShown }"
+ :class="{
+ 'gl-z-dropdown-menu!': idState.moreActionsShown,
+ 'is-sidebar-moved': glFeatures.movedMrSidebar,
+ }"
class="js-file-title file-title file-title-flex-parent"
data-qa-selector="file_title_container"
:data-qa-file-name="filePath"
diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
index a077c8ae3af..8553bdd3020 100644
--- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
+++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
@@ -4,6 +4,7 @@ import { truncate } from '~/lib/utils/text_utility';
import { n__ } from '~/locale';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants';
+import { HIDE_COMMENTS } from '../i18n';
export default {
components: {
@@ -55,6 +56,9 @@ export default {
return `${noteData.author.name}: ${note}`;
},
},
+ i18n: {
+ HIDE_COMMENTS,
+ },
};
</script>
@@ -62,8 +66,10 @@ export default {
<div class="diff-comment-avatar-holders">
<button
v-if="discussionsExpanded"
+ v-gl-tooltip
+ :title="$options.i18n.HIDE_COMMENTS"
type="button"
- :aria-label="__('Show comments')"
+ :aria-label="$options.i18n.HIDE_COMMENTS"
class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button"
@click="$emit('toggleLineDiscussions')"
>
diff --git a/app/assets/javascripts/diffs/components/diff_line.vue b/app/assets/javascripts/diffs/components/diff_line.vue
new file mode 100644
index 00000000000..448272549d3
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_line.vue
@@ -0,0 +1,35 @@
+<script>
+import DiffCodeQuality from './diff_code_quality.vue';
+
+export default {
+ components: {
+ DiffCodeQuality,
+ },
+ props: {
+ line: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ parsedCodeQuality() {
+ return (this.line.left ?? this.line.right)?.codequality;
+ },
+ codeQualityLineNumber() {
+ return this.parsedCodeQuality[0].line;
+ },
+ },
+ methods: {
+ hideCodeQualityFindings() {
+ this.$emit('hideCodeQualityFindings', this.codeQualityLineNumber);
+ },
+ },
+};
+</script>
+
+<template>
+ <diff-code-quality
+ :code-quality="parsedCodeQuality"
+ @hideCodeQualityFindings="hideCodeQualityFindings"
+ />
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 467a0f8d2db..f63ab1bb067 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -7,7 +7,7 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue';
import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils';
-import noteForm from '~/notes/components/note_form.vue';
+import NoteForm from '~/notes/components/note_form.vue';
import autosave from '~/notes/mixins/autosave';
import {
DIFF_NOTE_TYPE,
@@ -18,7 +18,7 @@ import {
export default {
components: {
- noteForm,
+ NoteForm,
MultilineCommentForm,
},
mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()],
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 63c5aedd7ce..e5695c4390f 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -64,6 +64,11 @@ export default {
type: Function,
required: true,
},
+ codeQualityExpanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
classNameMap: memoize(
(props) => {
@@ -272,6 +277,7 @@ export default {
<component
:is="$options.CodeQualityGutterIcon"
v-if="$options.showCodequalityLeft(props)"
+ :code-quality-expanded="props.codeQualityExpanded"
:codequality="props.line.left.codequality"
:file-path="props.filePath"
@showCodeQualityFindings="
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index ea94df1ad5b..91bf3283379 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -9,7 +9,7 @@ import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
import { hide } from '~/tooltips';
import { pickDirection } from '../utils/diff_line';
import DiffCommentCell from './diff_comment_cell.vue';
-import DiffCodeQuality from './diff_code_quality.vue';
+import DiffLine from './diff_line.vue';
import DiffExpansionCell from './diff_expansion_cell.vue';
import DiffRow from './diff_row.vue';
import { isHighlighted } from './diff_row_utils';
@@ -18,8 +18,8 @@ export default {
components: {
DiffExpansionCell,
DiffRow,
+ DiffLine,
DiffCommentCell,
- DiffCodeQuality,
DraftNote,
},
directives: {
@@ -96,10 +96,6 @@ export default {
}
this.idState.dragStart = line;
},
- parseCodeQuality(line) {
- return (line.left ?? line.right)?.codequality;
- },
-
hideCodeQualityFindings(line) {
const index = this.codeQualityExpandedLines.indexOf(line);
if (index > -1) {
@@ -179,7 +175,7 @@ export default {
);
},
getCodeQualityLine(line) {
- return this.parseCodeQuality(line)?.[0]?.line;
+ return (line.left ?? line.right)?.codequality?.[0]?.line;
},
},
userColorScheme: window.gon.user_color_scheme,
@@ -234,6 +230,7 @@ export default {
:is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
:inline="inline"
:index="index"
+ :code-quality-expanded="codeQualityExpandedLines.includes(getCodeQualityLine(line))"
:is-highlighted="isHighlighted(line)"
:file-line-coverage="fileLineCoverage"
:coverage-loaded="coverageLoaded"
@@ -248,15 +245,13 @@ export default {
@startdragging="onStartDragging"
@stopdragging="onStopDragging"
/>
-
- <diff-code-quality
+ <diff-line
v-if="
glFeatures.refactorCodeQualityInlineFindings &&
codeQualityExpandedLines.includes(getCodeQualityLine(line))
"
:key="line.line_code"
- :line="getCodeQualityLine(line)"
- :code-quality="parseCodeQuality(line)"
+ :line="line"
@hideCodeQualityFindings="hideCodeQualityFindings"
/>
<div
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 1cc96ef3d54..6c0c9c4e1d0 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -71,15 +71,12 @@ export const DIFF_FILE_MANUAL_COLLAPSE = 'manual';
export const STATE_IDLING = 'idle';
export const STATE_LOADING = 'loading';
export const STATE_ERRORED = 'errored';
-export const STATE_PENDING_REVIEW = 'pending_comments';
// State machine transitions
export const TRANSITION_LOAD_START = 'LOAD_START';
export const TRANSITION_LOAD_ERROR = 'LOAD_ERROR';
export const TRANSITION_LOAD_SUCCEED = 'LOAD_SUCCEED';
export const TRANSITION_ACKNOWLEDGE_ERROR = 'ACKNOWLEDGE_ERROR';
-export const TRANSITION_HAS_PENDING_REVIEW = 'PENDING_REVIEW';
-export const TRANSITION_NO_REVIEW = 'NO_REVIEW';
export const RENAMED_DIFF_TRANSITIONS = {
[`${STATE_IDLING}:${TRANSITION_LOAD_START}`]: STATE_LOADING,
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
index e617890af2e..f7f4aad3ad0 100644
--- a/app/assets/javascripts/diffs/i18n.js
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -47,3 +47,5 @@ export const CONFLICT_TEXT = {
'Conflict: This file was added both in the source and target branches, but with different contents.',
),
};
+
+export const HIDE_COMMENTS = __('Hide comments');
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 1691da34c6d..b4ff5e4f250 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -3,7 +3,7 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils';
import eventHub from '../notes/event_hub';
-import diffsApp from './components/app.vue';
+import DiffsApp from './components/app.vue';
import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants';
import { getReviewsForMergeRequest } from './utils/file_reviews';
@@ -14,7 +14,7 @@ export default function initDiffsApp(store) {
el: '#js-diffs-app',
name: 'MergeRequestDiffs',
components: {
- diffsApp,
+ DiffsApp,
},
store,
data() {
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index 7a2c9a8600e..f22a0705b3d 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -20,13 +20,13 @@ import {
} from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { s__, n__ } from '~/locale';
-import instanceComponent from '~/vue_shared/components/deployment_instance.vue';
+import InstanceComponent from '~/vue_shared/components/deployment_instance.vue';
import { STATUS_MAP, CANARY_STATUS } from '../constants';
import CanaryIngress from './canary_ingress.vue';
export default {
components: {
- instanceComponent,
+ InstanceComponent,
CanaryIngress,
GlIcon,
GlLoadingIcon,
diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue
index 19284b26d51..3475b38c8c9 100644
--- a/app/assets/javascripts/environments/components/deployment.vue
+++ b/app/assets/javascripts/environments/components/deployment.vue
@@ -1,17 +1,17 @@
<script>
import {
GlBadge,
- GlButton,
- GlCollapse,
GlIcon,
GlLink,
+ GlLoadingIcon,
GlTooltipDirective as GlTooltip,
GlTruncate,
} from '@gitlab/ui';
-import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { __, s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import createFlash from '~/flash';
+import deploymentDetails from '../graphql/queries/deployment_details.query.graphql';
import DeploymentStatusBadge from './deployment_status_badge.vue';
import Commit from './commit.vue';
@@ -21,16 +21,16 @@ export default {
Commit,
DeploymentStatusBadge,
GlBadge,
- GlButton,
- GlCollapse,
GlIcon,
GlLink,
GlTruncate,
+ GlLoadingIcon,
TimeAgoTooltip,
},
directives: {
GlTooltip,
},
+ inject: ['projectPath'],
props: {
deployment: {
type: Object,
@@ -41,9 +41,11 @@ export default {
default: false,
required: false,
},
- },
- data() {
- return { visible: false };
+ visible: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
computed: {
status() {
@@ -52,26 +54,21 @@ export default {
iid() {
return this.deployment?.iid;
},
+ isTag() {
+ return this.deployment?.tag;
+ },
shortSha() {
return this.commit?.shortId;
},
createdAt() {
return this.deployment?.createdAt;
},
- isMobile() {
- return !GlBreakpointInstance.isDesktop();
- },
- detailsButton() {
- return this.visible
- ? { text: this.$options.i18n.hideDetails, icon: 'expand-up' }
- : { text: this.$options.i18n.showDetails, icon: 'expand-down' };
- },
- detailsButtonClasses() {
- return this.isMobile ? 'gl-sr-only' : '';
- },
commit() {
return this.deployment?.commit;
},
+ commitPath() {
+ return this.commit?.commitPath;
+ },
user() {
return this.deployment?.user;
},
@@ -90,9 +87,6 @@ export default {
jobPath() {
return this.deployable?.buildPath;
},
- refLabel() {
- return this.deployment?.tag ? this.$options.i18n.tag : this.$options.i18n.branch;
- },
ref() {
return this.deployment?.ref;
},
@@ -105,10 +99,35 @@ export default {
needsApproval() {
return this.deployment.pendingApprovalCount > 0;
},
+ hasTags() {
+ return this.tags?.length > 0;
+ },
+ displayTags() {
+ return this.tags?.slice(0, 5);
+ },
},
- methods: {
- toggleCollapse() {
- this.visible = !this.visible;
+ apollo: {
+ tags: {
+ query: deploymentDetails,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ iid: this.deployment.iid,
+ };
+ },
+ update(data) {
+ return data?.project?.deployment?.tags;
+ },
+ error(error) {
+ createFlash({
+ message: this.$options.i18n.LOAD_ERROR_MESSAGE,
+ captureError: true,
+ error,
+ });
+ },
+ skip() {
+ return !this.visible;
+ },
},
},
i18n: {
@@ -116,14 +135,12 @@ export default {
deploymentId: s__('Deployment|Deployment ID'),
copyButton: __('Copy commit SHA'),
commitSha: __('Commit SHA'),
- showDetails: __('Show details'),
- hideDetails: __('Hide details'),
triggerer: s__('Deployment|Triggerer'),
needsApproval: s__('Deployment|Needs Approval'),
job: __('Job'),
api: __('API'),
branch: __('Branch'),
- tag: __('Tag'),
+ tags: __('Tags'),
},
headerClasses: [
'gl-display-flex',
@@ -179,7 +196,9 @@ export default {
class="gl-font-monospace gl-display-flex gl-align-items-center"
>
<gl-icon ref="deployment-commit-icon" name="commit" class="gl-mr-2" />
- <span v-gl-tooltip :title="$options.i18n.commitSha">{{ shortSha }}</span>
+ <gl-link v-gl-tooltip :title="$options.i18n.commitSha" :href="commitPath">
+ {{ shortSha }}
+ </gl-link>
<clipboard-button
:text="shortSha"
category="tertiary"
@@ -195,54 +214,66 @@ export default {
</time-ago-tooltip>
</div>
</div>
- <gl-button
- ref="details-toggle"
- category="tertiary"
- :icon="detailsButton.icon"
- :button-text-classes="detailsButtonClasses"
- @click="toggleCollapse"
- >
- {{ detailsButton.text }}
- </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"
+ >
+ <div v-if="user" class="gl-display-flex gl-flex-direction-column gl-md-max-w-15p">
+ <span class="gl-text-gray-500">{{ $options.i18n.triggerer }}</span>
+ <gl-link :href="userPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="username" with-tooltip />
+ </gl-link>
+ </div>
<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"
+ class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
>
- <div v-if="user" class="gl-display-flex gl-flex-direction-column gl-md-max-w-15p">
- <span class="gl-text-gray-500">{{ $options.i18n.triggerer }}</span>
- <gl-link :href="userPath" class="gl-font-monospace gl-mt-3">
- <gl-truncate :text="username" with-tooltip />
- </gl-link>
- </div>
- <div
- class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
- >
- <span class="gl-text-gray-500" :class="{ 'gl-ml-3': !deployable }">
- {{ $options.i18n.job }}
- </span>
- <gl-link v-if="jobPath" :href="jobPath" class="gl-font-monospace gl-mt-3">
- <gl-truncate :text="jobName" with-tooltip position="middle" />
- </gl-link>
- <span v-else-if="jobName" class="gl-font-monospace gl-mt-3">
- <gl-truncate :text="jobName" with-tooltip position="middle" />
- </span>
- <gl-badge v-else class="gl-font-monospace gl-mt-3" variant="info">
- {{ $options.i18n.api }}
- </gl-badge>
- </div>
- <div
- v-if="ref"
- class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
- >
- <span class="gl-text-gray-500">{{ refLabel }}</span>
- <gl-link :href="refPath" class="gl-font-monospace gl-mt-3">
- <gl-truncate :text="refName" with-tooltip />
+ <span class="gl-text-gray-500" :class="{ 'gl-ml-3': !deployable }">
+ {{ $options.i18n.job }}
+ </span>
+ <gl-link v-if="jobPath" :href="jobPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="jobName" with-tooltip position="middle" />
+ </gl-link>
+ <span v-else-if="jobName" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="jobName" with-tooltip position="middle" />
+ </span>
+ <gl-badge v-else class="gl-font-monospace gl-mt-3" variant="info">
+ {{ $options.i18n.api }}
+ </gl-badge>
+ </div>
+ <div
+ v-if="ref && !isTag"
+ class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
+ >
+ <span class="gl-text-gray-500">{{ $options.i18n.branch }}</span>
+ <gl-link :href="refPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="refName" with-tooltip />
+ </gl-link>
+ </div>
+ <div
+ v-if="hasTags || $apollo.queries.tags.loading"
+ class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
+ >
+ <span class="gl-text-gray-500">{{ $options.i18n.tags }}</span>
+ <gl-loading-icon
+ v-if="$apollo.queries.tags.loading"
+ class="gl-font-monospace gl-mt-3"
+ size="sm"
+ inline
+ />
+ <div v-if="hasTags" class="gl-display-flex gl-flex-direction-row">
+ <gl-link
+ v-for="(tag, ndx) in displayTags"
+ :key="tag.name"
+ :href="tag.path"
+ class="gl-font-monospace gl-mt-3 gl-mr-3"
+ >
+ {{ tag.name }}<span v-if="ndx + 1 < tags.length">, </span>
</gl-link>
+ <div v-if="tags.length > 5" class="gl-font-monospace gl-mt-3 gl-mr-3">...</div>
</div>
</div>
- </gl-collapse>
+ </div>
</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 75bd473497b..9a100e0199e 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -310,6 +310,7 @@ export default {
<div v-if="lastDeployment" :class="$options.deploymentClasses">
<deployment
:deployment="lastDeployment"
+ :visible="visible"
:class="{ 'gl-ml-7': inFolder }"
latest
class="gl-pl-4"
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 4e5fe511f8a..1a32de30de0 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import Translate from '~/vue_shared/translate';
-import environmentsFolderApp from './environments_folder_view.vue';
+import EnvironmentsFolderApp from './environments_folder_view.vue';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -17,7 +17,7 @@ export default () => {
return new Vue({
el,
components: {
- environmentsFolderApp,
+ EnvironmentsFolderApp,
},
apolloProvider,
provide: {
diff --git a/app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql
new file mode 100644
index 00000000000..baed777bd07
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql
@@ -0,0 +1,13 @@
+query getDeploymentDetails($projectPath: ID!, $iid: ID!) {
+ project(fullPath: $projectPath) {
+ id
+ deployment(iid: $iid) {
+ id
+ iid
+ tags {
+ name
+ path
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 645c2456c6e..93510870915 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -163,7 +163,6 @@ export default {
v-gl-modal="'configure-feature-flags'"
variant="confirm"
category="secondary"
- data-qa-selector="configure_feature_flags_button"
data-testid="ff-configure-button"
class="gl-mb-3"
>
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index a8670caf5b2..a6781cffaec 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -81,6 +81,7 @@ export default class FilterableList {
onFilterSuccess(response, queryData) {
if (response.data.html) {
+ // eslint-disable-next-line no-unsanitized/property
this.listHolderElement.innerHTML = response.data.html;
}
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
index 4c2f55fd174..679c8caffdb 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
@@ -69,14 +69,18 @@ export default {
</script>
<template>
<div>
- <div v-if="!isLocalStorageAvailable" ref="localStorageNote" class="dropdown-info-note">
+ <div
+ v-if="!isLocalStorageAvailable"
+ data-testid="local-storage-note"
+ class="dropdown-info-note"
+ >
{{ __('This feature requires local storage to be enabled') }}
</div>
<ul v-else-if="hasItems">
<li
v-for="(item, index) in processedItems"
- ref="dropdownItem"
:key="`processed-items-${index}`"
+ data-testid="dropdown-item"
>
<button
type="button"
@@ -100,7 +104,7 @@ export default {
<li class="divider"></li>
<li>
<button
- ref="clearButton"
+ data-testid="clear-button"
type="button"
class="filtered-search-history-clear-button"
@click="onRequestClearRecentSearches($event)"
@@ -109,7 +113,7 @@ export default {
</button>
</li>
</ul>
- <div v-else ref="dropdownNote" class="dropdown-info-note">
+ <div v-else data-testid="dropdown-note" class="dropdown-info-note">
{{ __("You don't have any recent searches") }}
</div>
</div>
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index 5adc074b3ce..aeea66bf51c 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -75,6 +75,7 @@ export default class DropdownEmoji extends FilteredSearchDropdown {
const name = valueElement.innerText;
const emojiTag = this.glEmojiTag(name);
const emojiElement = dropdownItem.querySelector('gl-emoji');
+ // eslint-disable-next-line no-unsanitized/property
emojiElement.outerHTML = emojiTag;
}
});
diff --git a/app/assets/javascripts/filtered_search/droplab/drop_down.js b/app/assets/javascripts/filtered_search/droplab/drop_down.js
index 398a7b26677..e7edc678773 100644
--- a/app/assets/javascripts/filtered_search/droplab/drop_down.js
+++ b/app/assets/javascripts/filtered_search/droplab/drop_down.js
@@ -107,7 +107,7 @@ class DropDown {
}
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
-
+ // eslint-disable-next-line no-unsanitized/property
renderableList.innerHTML = children.join('');
const listEvent = new CustomEvent('render.dl', {
@@ -121,7 +121,7 @@ class DropDown {
renderChildren(data) {
const html = utils.template(this.templateString, data);
const template = document.createElement('div');
-
+ // eslint-disable-next-line no-unsanitized/property
template.innerHTML = html;
DropDown.setImagesSrc(template);
template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block';
diff --git a/app/assets/javascripts/filtered_search/droplab/hook_button.js b/app/assets/javascripts/filtered_search/droplab/hook_button.js
index c51d6167fa3..805905e7750 100644
--- a/app/assets/javascripts/filtered_search/droplab/hook_button.js
+++ b/app/assets/javascripts/filtered_search/droplab/hook_button.js
@@ -42,6 +42,7 @@ class HookButton extends Hook {
}
restoreInitialState() {
+ // eslint-disable-next-line no-unsanitized/property
this.list.list.innerHTML = this.list.initialState;
}
diff --git a/app/assets/javascripts/filtered_search/droplab/hook_input.js b/app/assets/javascripts/filtered_search/droplab/hook_input.js
index c523dae347f..32dfe0372bb 100644
--- a/app/assets/javascripts/filtered_search/droplab/hook_input.js
+++ b/app/assets/javascripts/filtered_search/droplab/hook_input.js
@@ -97,6 +97,7 @@ class HookInput extends Hook {
}
restoreInitialState() {
+ // eslint-disable-next-line no-unsanitized/property
this.list.list.innerHTML = this.list.initialState;
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index 7143cb50ea6..0c01220a7be 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -122,6 +122,7 @@ export default class FilteredSearchVisualTokens {
const hasOperator = Boolean(operator);
if (value) {
+ // eslint-disable-next-line no-unsanitized/property
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
uppercaseTokenName,
@@ -138,6 +139,7 @@ export default class FilteredSearchVisualTokens {
operatorHTML = '<div class="operator"></div>';
}
+ // eslint-disable-next-line no-unsanitized/property
li.innerHTML = nameHTML + operatorHTML;
}
@@ -160,6 +162,8 @@ export default class FilteredSearchVisualTokens {
if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
const operator = FilteredSearchVisualTokens.getLastTokenOperator();
+
+ // eslint-disable-next-line no-unsanitized/property
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
hasOperator: Boolean(operator),
});
@@ -293,6 +297,7 @@ export default class FilteredSearchVisualTokens {
const button = lastVisualToken.querySelector('.selectable');
const valueContainer = lastVisualToken.querySelector('.value-container');
button.removeChild(valueContainer);
+ // eslint-disable-next-line no-unsanitized/property
lastVisualToken.innerHTML = button.innerHTML;
} else if (operator) {
lastVisualToken.removeChild(operator);
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index 707add10009..0d144398531 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -47,6 +47,7 @@ export default class VisualTokenValue {
/* eslint-disable no-param-reassign */
tokenValueContainer.dataset.originalValue = tokenValue;
+ // eslint-disable-next-line no-unsanitized/property
tokenValueElement.innerHTML = `
<img class="avatar s20" src="${user.avatar_url}" alt="">
${escape(user.name)}
@@ -152,6 +153,7 @@ export default class VisualTokenValue {
}
container.dataset.originalValue = value;
+ // eslint-disable-next-line no-unsanitized/property
element.innerHTML = Emoji.glEmojiTag(value);
});
}
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 5a47e76d597..edf83a33812 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -236,11 +236,13 @@ const createFlash = function createFlash({
if (!flashContainer) return null;
+ // eslint-disable-next-line no-unsanitized/property
flashContainer.innerHTML = createFlashEl(message, type);
const flashEl = flashContainer.querySelector(`.flash-${type}`);
if (actionConfig) {
+ // eslint-disable-next-line no-unsanitized/method
flashEl.insertAdjacentHTML('beforeend', createAction(actionConfig));
if (actionConfig.clickHandler) {
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
index 1da0b88c9e9..c0bfcf9c4a9 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
@@ -58,7 +58,7 @@ export default {
<template>
<div class="frequent-items-list-container">
- <ul ref="frequentItemsList" class="list-unstyled">
+ <ul data-testid="frequent-items-list" class="list-unstyled">
<li
v-if="isListEmpty"
:class="{ 'section-failure': isFetchFailed }"
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 9fb69a3cae3..33ab1d5cd7f 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -79,16 +79,19 @@ export default {
:project-name="itemName"
aria-hidden="true"
/>
- <div ref="frequentItemsItemMetadataContainer" class="frequent-items-item-metadata-container">
+ <div
+ data-testid="frequent-items-item-metadata-container"
+ class="frequent-items-item-metadata-container"
+ >
<div
- ref="frequentItemsItemTitle"
v-safe-html="highlightedItemName"
+ data-testid="frequent-items-item-title"
:title="itemName"
class="frequent-items-item-title"
></div>
<div
v-if="namespace"
- ref="frequentItemsItemNamespace"
+ data-testid="frequent-items-item-namespace"
:title="namespace"
class="frequent-items-item-namespace"
>
diff --git a/app/assets/javascripts/google_cloud/databases/index.js b/app/assets/javascripts/google_cloud/databases/index.js
deleted file mode 100644
index e240a1116e8..00000000000
--- a/app/assets/javascripts/google_cloud/databases/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import Vue from 'vue';
-import Panel from './panel.vue';
-
-export default (containerId = '#js-google-cloud-databases') => {
- const element = document.querySelector(containerId);
- const { ...attrs } = JSON.parse(element.getAttribute('data'));
- return new Vue({
- el: element,
- render: (createElement) => createElement(Panel, { attrs }),
- });
-};
diff --git a/app/assets/javascripts/google_cloud/databases/init_index.js b/app/assets/javascripts/google_cloud/databases/init_index.js
new file mode 100644
index 00000000000..931143833cb
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/init_index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Panel from './panel.vue';
+
+export default () => {
+ const element = document.querySelector('#js-google-cloud-databases');
+ const attrs = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Panel, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/databases/init_new.js b/app/assets/javascripts/google_cloud/databases/init_new.js
new file mode 100644
index 00000000000..3feb2dc2f98
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/init_new.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Form from './cloudsql/create_instance_form.vue';
+
+export default () => {
+ const element = document.querySelector('#js-google-cloud-databases-cloudsql-form');
+ const attrs = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Form, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/databases/panel.vue b/app/assets/javascripts/google_cloud/databases/panel.vue
index e2f18c286a5..8b91c508871 100644
--- a/app/assets/javascripts/google_cloud/databases/panel.vue
+++ b/app/assets/javascripts/google_cloud/databases/panel.vue
@@ -1,11 +1,15 @@
<script>
import GoogleCloudMenu from '../components/google_cloud_menu.vue';
import IncubationBanner from '../components/incubation_banner.vue';
+import InstanceTable from './cloudsql/instance_table.vue';
+import ServiceTable from './service_table.vue';
export default {
components: {
IncubationBanner,
+ InstanceTable,
GoogleCloudMenu,
+ ServiceTable,
},
props: {
configurationUrl: {
@@ -20,6 +24,26 @@ export default {
type: String,
required: true,
},
+ cloudsqlPostgresUrl: {
+ type: String,
+ required: true,
+ },
+ cloudsqlMysqlUrl: {
+ type: String,
+ required: true,
+ },
+ cloudsqlSqlserverUrl: {
+ type: String,
+ required: true,
+ },
+ cloudsqlInstances: {
+ type: Array,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
},
};
</script>
@@ -34,5 +58,19 @@ export default {
:deployments-url="deploymentsUrl"
:databases-url="databasesUrl"
/>
+
+ <service-table
+ alloydb-postgres-url="#"
+ :cloudsql-mysql-url="cloudsqlMysqlUrl"
+ :cloudsql-postgres-url="cloudsqlPostgresUrl"
+ :cloudsql-sqlserver-url="cloudsqlSqlserverUrl"
+ firestore-url="#"
+ memorystore-redis-url="#"
+ />
+
+ <instance-table
+ :cloudsql-instances="cloudsqlInstances"
+ :empty-illustration-url="emptyIllustrationUrl"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index c8204f397ff..5b0bcfa963b 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -140,17 +140,6 @@ export const trackSaasTrialGroup = () => {
});
};
-export const trackSaasTrialProject = () => {
- if (!isSupported()) {
- return;
- }
-
- const form = document.getElementById('new_project');
- form.addEventListener('submit', () => {
- pushEvent('saasTrialProject');
- });
-};
-
export const trackProjectImport = () => {
if (!isSupported()) {
return;
@@ -290,3 +279,11 @@ export const trackCombinedGroupProjectForm = () => {
pushEvent('combinedGroupProjectFormSubmit');
});
};
+
+export const trackCompanyForm = (aboutYourCompanyType) => {
+ if (!isSupported()) {
+ return;
+ }
+
+ pushEvent('aboutYourCompanyFormSubmit', { aboutYourCompanyType });
+};
diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
index fb771d7ec8a..45dbfb30704 100644
--- a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
@@ -1,4 +1,5 @@
fragment TimelogFragment on Timelog {
+ __typename
id
timeSpent
user {
diff --git a/app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql
new file mode 100644
index 00000000000..dbe6ad9f059
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql
@@ -0,0 +1,13 @@
+#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
+
+fragment IssueTimeTrackingFragment on Issue {
+ __typename
+ id
+ humanTotalTimeSpent
+ totalTimeSpent
+ timelogs {
+ nodes {
+ ...TimelogFragment
+ }
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql
new file mode 100644
index 00000000000..68d3c02cf2e
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql
@@ -0,0 +1,13 @@
+#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
+
+fragment MergeRequestTimeTrackingFragment on MergeRequest {
+ __typename
+ id
+ humanTotalTimeSpent
+ totalTimeSpent
+ timelogs {
+ nodes {
+ ...TimelogFragment
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index b70c06fddea..e86103c332b 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -1,10 +1,11 @@
import produce from 'immer';
-import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { concatPagination } from '@apollo/client/utilities';
+import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
-import { WIDGET_TYPE_LABELS } from '../constants';
-import typeDefs from './typedefs.graphql';
-import workItemQuery from './work_item.query.graphql';
+import typeDefs from '~/work_items/graphql/typedefs.graphql';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import { WIDGET_TYPE_LABELS } from '~/work_items/constants';
export const temporaryConfig = {
typeDefs,
@@ -13,6 +14,13 @@ export const temporaryConfig = {
LocalWorkItemWidget: ['LocalWorkItemLabels'],
},
typePolicies: {
+ Project: {
+ fields: {
+ projectMembers: {
+ keyArgs: ['fullPath', 'search', 'relations', 'first'],
+ },
+ },
+ },
WorkItem: {
fields: {
mockWidgets: {
@@ -36,12 +44,24 @@ export const temporaryConfig = {
},
},
},
+ MemberInterfaceConnection: {
+ fields: {
+ nodes: concatPagination(),
+ },
+ },
},
},
};
export const resolvers = {
Mutation: {
+ updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
+ const sourceData = cache.readQuery({ query: getIssueStateQuery });
+ const data = produce(sourceData, (draftData) => {
+ draftData.issueState = { issueType, isDirty };
+ });
+ cache.writeQuery({ query: getIssueStateQuery, data });
+ },
localUpdateWorkItem(_, { input }, { cache }) {
const sourceData = cache.readQuery({
query: workItemQuery,
@@ -66,12 +86,8 @@ export const resolvers = {
},
};
-export function createApolloProvider() {
- Vue.use(VueApollo);
-
- const defaultClient = createDefaultClient(resolvers, temporaryConfig);
+export const defaultClient = createDefaultClient(resolvers, temporaryConfig);
- return new VueApollo({
- defaultClient,
- });
-}
+export const apolloProvider = new VueApollo({
+ defaultClient,
+});
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index eac325f184f..72dbf9e7b7b 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -140,6 +140,7 @@
"WorkItemWidgetAssignees",
"WorkItemWidgetDescription",
"WorkItemWidgetHierarchy",
+ "WorkItemWidgetIteration",
"WorkItemWidgetLabels",
"WorkItemWidgetStartAndDueDate",
"WorkItemWidgetVerificationStatus",
diff --git a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql
index 0c0a874d950..0c0a874d950 100644
--- a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
index bb34e4032f4..f64c4276deb 100644
--- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
@@ -1,10 +1,20 @@
#import "../fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
-query projectUsersSearch($search: String!, $fullPath: ID!) {
+query projectUsersSearch($search: String!, $fullPath: ID!, $after: String, $first: Int) {
workspace: project(fullPath: $fullPath) {
id
- users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
+ users: projectMembers(
+ search: $search
+ relations: [DIRECT, INHERITED, INVITED_GROUPS]
+ first: $first
+ after: $after
+ ) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ startCursor
+ }
nodes {
id
user {
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index cd5521c599e..0bd7371d39b 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -17,11 +17,6 @@ export default {
GlLoadingIcon,
EmptyState,
},
- inject: {
- renderEmptyState: {
- default: false,
- },
- },
props: {
action: {
type: String,
@@ -45,6 +40,11 @@ export default {
type: Boolean,
required: true,
},
+ renderEmptyState: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -224,6 +224,9 @@ export default {
},
showLegacyEmptyState() {
const { containerEl } = this;
+
+ if (!containerEl) return;
+
const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS);
const emptyStateEl = containerEl.querySelector('.empty-state');
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 2f182b86d2c..961af800971 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -16,15 +16,15 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
-import { VISIBILITY_LEVELS_ENUM } from '~/visibility_level/constants';
+import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants';
import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants';
import eventHub from '../event_hub';
-import itemActions from './item_actions.vue';
-import itemCaret from './item_caret.vue';
-import itemStats from './item_stats.vue';
-import itemTypeIcon from './item_type_icon.vue';
+import ItemActions from './item_actions.vue';
+import ItemCaret from './item_caret.vue';
+import ItemStats from './item_stats.vue';
+import ItemTypeIcon from './item_type_icon.vue';
export default {
directives: {
@@ -41,10 +41,10 @@ export default {
GlPopover,
GlLink,
UserAccessRoleBadge,
- itemCaret,
- itemTypeIcon,
- itemActions,
- itemStats,
+ ItemCaret,
+ ItemTypeIcon,
+ ItemActions,
+ ItemStats,
},
inject: ['currentGroupVisibility'],
props: {
@@ -111,8 +111,8 @@ export default {
shouldShowVisibilityWarning() {
return (
this.action === 'shared' &&
- VISIBILITY_LEVELS_ENUM[this.group.visibility] >
- VISIBILITY_LEVELS_ENUM[this.currentGroupVisibility]
+ VISIBILITY_LEVELS_STRING_TO_INTEGER[this.group.visibility] >
+ VISIBILITY_LEVELS_STRING_TO_INTEGER[this.currentGroupVisibility]
);
},
},
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index 2aa812250a0..a4c163b0a81 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -1,19 +1,19 @@
<script>
import { GlBadge } from '@gitlab/ui';
import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal';
-import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
ITEM_TYPE,
VISIBILITY_TYPE_ICON,
GROUP_VISIBILITY_TYPE,
PROJECT_VISIBILITY_TYPE,
} from '../constants';
-import itemStatsValue from './item_stats_value.vue';
+import ItemStatsValue from './item_stats_value.vue';
export default {
components: {
- timeAgoTooltip,
- itemStatsValue,
+ TimeAgoTooltip,
+ ItemStatsValue,
GlBadge,
},
mixins: [isProjectPendingRemoval],
diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue
new file mode 100644
index 00000000000..325e42af0f8
--- /dev/null
+++ b/app/assets/javascripts/groups/components/overview_tabs.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlTabs, GlTab } from '@gitlab/ui';
+import { isString } from 'lodash';
+import { __ } from '~/locale';
+import GroupsStore from '../store/groups_store';
+import GroupsService from '../service/groups_service';
+import {
+ ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
+ ACTIVE_TAB_SHARED,
+ ACTIVE_TAB_ARCHIVED,
+} from '../constants';
+import GroupsApp from './app.vue';
+
+export default {
+ components: { GlTabs, GlTab, GroupsApp },
+ inject: ['endpoints'],
+ data() {
+ return {
+ tabs: [
+ {
+ title: this.$options.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS],
+ key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
+ renderEmptyState: true,
+ lazy: false,
+ service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]),
+ store: new GroupsStore({ showSchemaMarkup: true }),
+ },
+ {
+ title: this.$options.i18n[ACTIVE_TAB_SHARED],
+ key: ACTIVE_TAB_SHARED,
+ renderEmptyState: false,
+ lazy: true,
+ service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]),
+ store: new GroupsStore(),
+ },
+ {
+ title: this.$options.i18n[ACTIVE_TAB_ARCHIVED],
+ key: ACTIVE_TAB_ARCHIVED,
+ renderEmptyState: false,
+ lazy: true,
+ service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]),
+ store: new GroupsStore(),
+ },
+ ],
+ activeTabIndex: 0,
+ };
+ },
+ mounted() {
+ const activeTabIndex = this.tabs.findIndex((tab) => tab.key === this.$route.name);
+
+ if (activeTabIndex === -1) {
+ return;
+ }
+
+ this.activeTabIndex = activeTabIndex;
+ },
+ methods: {
+ handleTabInput(tabIndex) {
+ if (tabIndex === this.activeTabIndex) {
+ return;
+ }
+
+ this.activeTabIndex = tabIndex;
+
+ const tab = this.tabs[tabIndex];
+ tab.lazy = false;
+
+ // Vue router will convert `/` to `%2F` if you pass a string as a param
+ // If you pass an array as a param it will concatenate them with a `/`
+ // This makes sure we are always passing an array for the group param
+ const groupParam = isString(this.$route.params.group)
+ ? this.$route.params.group.split('/')
+ : this.$route.params.group;
+
+ this.$router.push({ name: tab.key, params: { group: groupParam } });
+ },
+ },
+ i18n: {
+ [ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]: __('Subgroups and projects'),
+ [ACTIVE_TAB_SHARED]: __('Shared projects'),
+ [ACTIVE_TAB_ARCHIVED]: __('Archived projects'),
+ },
+};
+</script>
+
+<template>
+ <gl-tabs content-class="gl-pt-0" :value="activeTabIndex" @input="handleTabInput">
+ <gl-tab
+ v-for="{ key, title, renderEmptyState, lazy, service, store } in tabs"
+ :key="key"
+ :title="title"
+ :lazy="lazy"
+ >
+ <groups-app
+ :action="key"
+ :service="service"
+ :store="store"
+ :hide-projects="false"
+ :render-empty-state="renderEmptyState"
+ />
+ </gl-tab>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/groups/components/visibility_level_dropdown.vue b/app/assets/javascripts/groups/components/visibility_level_dropdown.vue
deleted file mode 100644
index 0933045fc38..00000000000
--- a/app/assets/javascripts/groups/components/visibility_level_dropdown.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownItem,
- },
- props: {
- visibilityLevelOptions: {
- type: Array,
- required: true,
- },
- defaultLevel: {
- type: Number,
- required: true,
- },
- },
- data() {
- return {
- selectedOption: this.getDefaultOption(),
- };
- },
- methods: {
- getDefaultOption() {
- return this.visibilityLevelOptions.find((option) => option.level === this.defaultLevel);
- },
- onClick(option) {
- this.selectedOption = option;
- },
- },
-};
-</script>
-<template>
- <div>
- <input type="hidden" name="group[visibility_level]" :value="selectedOption.level" />
- <gl-dropdown :text="selectedOption.label" class="gl-w-full" menu-class="gl-w-full! gl-mb-0">
- <gl-dropdown-item
- v-for="option in visibilityLevelOptions"
- :key="option.level"
- :secondary-text="option.description"
- @click="onClick(option)"
- >
- <div class="gl-font-weight-bold gl-mb-1">{{ option.label }}</div>
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
-</template>
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 0d09ad9442b..223c2975c11 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -1,8 +1,8 @@
import { __, s__ } from '~/locale';
import {
- VISIBILITY_LEVEL_PRIVATE,
- VISIBILITY_LEVEL_INTERNAL,
- VISIBILITY_LEVEL_PUBLIC,
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ VISIBILITY_LEVEL_INTERNAL_STRING,
+ VISIBILITY_LEVEL_PUBLIC_STRING,
} from '~/visibility_level/constants';
export const MAX_CHILDREN_COUNT = 20;
@@ -34,29 +34,31 @@ export const ITEM_TYPE = {
};
export const GROUP_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC]: __(
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
'Public - The group and any public projects can be viewed without any authentication.',
),
- [VISIBILITY_LEVEL_INTERNAL]: __(
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
),
- [VISIBILITY_LEVEL_PRIVATE]: __(
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
'Private - The group and its projects can only be viewed by members.',
),
};
export const PROJECT_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC]: __('Public - The project can be accessed without any authentication.'),
- [VISIBILITY_LEVEL_INTERNAL]: __(
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
+ 'Public - The project can be accessed without any authentication.',
+ ),
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
'Internal - The project can be accessed by any logged in user except external users.',
),
- [VISIBILITY_LEVEL_PRIVATE]: __(
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
),
};
export const VISIBILITY_TYPE_ICON = {
- [VISIBILITY_LEVEL_PUBLIC]: 'earth',
- [VISIBILITY_LEVEL_INTERNAL]: 'shield',
- [VISIBILITY_LEVEL_PRIVATE]: 'lock',
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
};
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index a502fcd31ad..c3bf3f28509 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -4,9 +4,9 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import UserCallout from '~/user_callout';
import Translate from '../vue_shared/translate';
-import groupsApp from './components/app.vue';
-import groupFolderComponent from './components/group_folder.vue';
-import groupItemComponent from './components/group_item.vue';
+import GroupsApp from './components/app.vue';
+import GroupFolderComponent from './components/group_folder.vue';
+import GroupItemComponent from './components/group_item.vue';
import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants';
import GroupFilterableList from './groups_filterable_list';
import GroupsService from './service/groups_service';
@@ -33,8 +33,8 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
dataEl = containerEl.querySelector(CONTENT_LIST_CLASS);
}
- Vue.component('GroupFolder', groupFolderComponent);
- Vue.component('GroupItem', groupItemComponent);
+ Vue.component('GroupFolder', GroupFolderComponent);
+ Vue.component('GroupItem', GroupItemComponent);
Vue.use(GlToast);
@@ -42,7 +42,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
new Vue({
el,
components: {
- groupsApp,
+ GroupsApp,
},
provide() {
const {
@@ -52,7 +52,6 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
newSubgroupIllustration,
newProjectIllustration,
emptySubgroupIllustration,
- renderEmptyState,
canCreateSubgroups,
canCreateProjects,
currentGroupVisibility,
@@ -65,7 +64,6 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
newSubgroupIllustration,
newProjectIllustration,
emptySubgroupIllustration,
- renderEmptyState: parseBoolean(renderEmptyState),
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
currentGroupVisibility,
@@ -75,6 +73,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
const { dataset } = dataEl || this.$options.el;
const hideProjects = parseBoolean(dataset.hideProjects);
const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup);
+ const renderEmptyState = parseBoolean(dataset.renderEmptyState);
const service = new GroupsService(endpoint || dataset.endpoint);
const store = new GroupsStore({ hideProjects, showSchemaMarkup });
@@ -83,6 +82,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
store,
service,
hideProjects,
+ renderEmptyState,
loading: true,
containerId,
};
@@ -119,6 +119,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
store: this.store,
service: this.service,
hideProjects: this.hideProjects,
+ renderEmptyState: this.renderEmptyState,
containerId: this.containerId,
},
});
diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js
new file mode 100644
index 00000000000..4fa3682c729
--- /dev/null
+++ b/app/assets/javascripts/groups/init_overview_tabs.js
@@ -0,0 +1,78 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { GlToast } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import GroupFolder from './components/group_folder.vue';
+import GroupItem from './components/group_item.vue';
+import {
+ ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
+ ACTIVE_TAB_SHARED,
+ ACTIVE_TAB_ARCHIVED,
+} from './constants';
+import OverviewTabs from './components/overview_tabs.vue';
+
+export const createRouter = () => {
+ const routes = [
+ { name: ACTIVE_TAB_SHARED, path: '/groups/:group*/-/shared' },
+ { name: ACTIVE_TAB_ARCHIVED, path: '/groups/:group*/-/archived' },
+ { name: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, path: '/:group*' },
+ ];
+
+ const router = new VueRouter({
+ routes,
+ mode: 'history',
+ base: '/',
+ });
+
+ return router;
+};
+
+export const initGroupOverviewTabs = () => {
+ const el = document.getElementById('js-group-overview-tabs');
+
+ if (!el) return false;
+
+ Vue.component('GroupFolder', GroupFolder);
+ Vue.component('GroupItem', GroupItem);
+ Vue.use(GlToast);
+ Vue.use(VueRouter);
+
+ const router = createRouter();
+
+ const {
+ newSubgroupPath,
+ newProjectPath,
+ newSubgroupIllustration,
+ newProjectIllustration,
+ emptySubgroupIllustration,
+ canCreateSubgroups,
+ canCreateProjects,
+ currentGroupVisibility,
+ subgroupsAndProjectsEndpoint,
+ sharedProjectsEndpoint,
+ archivedProjectsEndpoint,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ router,
+ provide: {
+ newSubgroupPath,
+ newProjectPath,
+ newSubgroupIllustration,
+ newProjectIllustration,
+ emptySubgroupIllustration,
+ canCreateSubgroups: parseBoolean(canCreateSubgroups),
+ canCreateProjects: parseBoolean(canCreateProjects),
+ currentGroupVisibility,
+ endpoints: {
+ [ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]: subgroupsAndProjectsEndpoint,
+ [ACTIVE_TAB_SHARED]: sharedProjectsEndpoint,
+ [ACTIVE_TAB_ARCHIVED]: archivedProjectsEndpoint,
+ },
+ },
+ render(createElement) {
+ return createElement(OverviewTabs);
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/visibility_level.js b/app/assets/javascripts/groups/visibility_level.js
deleted file mode 100644
index d570b5e65ac..00000000000
--- a/app/assets/javascripts/groups/visibility_level.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import Vue from 'vue';
-import VisibilityLevelDropdown from './components/visibility_level_dropdown.vue';
-
-export default () => {
- const el = document.querySelector('.js-visibility-level-dropdown');
-
- if (!el) {
- return null;
- }
-
- const { visibilityLevelOptions, defaultLevel } = el.dataset;
-
- return new Vue({
- el,
- render(createElement) {
- return createElement(VisibilityLevelDropdown, {
- props: {
- visibilityLevelOptions: JSON.parse(visibilityLevelOptions),
- defaultLevel: Number(defaultLevel),
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index 3a20fb0216d..332ccee510f 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -26,11 +26,17 @@ export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
-export const ISSUES_CATEGORY = 'Recent issues';
+export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues');
-export const MERGE_REQUEST_CATEGORY = 'Recent merge requests';
+export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests');
-export const RECENT_EPICS_CATEGORY = 'Recent epics';
+export const RECENT_EPICS_CATEGORY = s__('GlobalSearch|Recent epics');
+
+export const IN_THIS_PROJECT_CATEGORY = s__('GlobalSearch|In this project');
+
+export const SETTINGS_CATEGORY = s__('GlobalSearch|Settings');
+
+export const HELP_CATEGORY = s__('GlobalSearch|Help');
export const LARGE_AVATAR_PX = 32;
@@ -55,3 +61,16 @@ export const HEADER_INIT_EVENTS = ['input', 'focus'];
export const IS_SEARCHING = 'is-searching';
export const IS_FOCUSED = 'is-focused';
export const IS_NOT_FOCUSED = 'is-not-focused';
+
+export const DROPDOWN_ORDER = [
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ IN_THIS_PROJECT_CATEGORY,
+ SETTINGS_CATEGORY,
+ HELP_CATEGORY,
+];
+
+export const FETCH_TYPES = ['generic', 'search'];
diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js
index 3a86dcca409..a0f9e594506 100644
--- a/app/assets/javascripts/header_search/store/actions.js
+++ b/app/assets/javascripts/header_search/store/actions.js
@@ -1,10 +1,26 @@
+import { omitBy, isNil } from 'lodash';
+import { objectToQuery } from '~/lib/utils/url_utility';
import axios from '~/lib/utils/axios_utils';
+import { FETCH_TYPES } from '../constants';
import * as types from './mutation_types';
-export const fetchAutocompleteOptions = ({ commit, getters }) => {
- commit(types.REQUEST_AUTOCOMPLETE);
+export const autocompleteQuery = ({ state, fetchType }) => {
+ const query = omitBy(
+ {
+ term: state.search,
+ project_id: state.searchContext?.project?.id,
+ project_ref: state.searchContext?.ref,
+ filter: fetchType,
+ },
+ isNil,
+ );
+
+ return `${state.autocompletePath}?${objectToQuery(query)}`;
+};
+
+const doFetch = ({ commit, state, fetchType }) => {
return axios
- .get(getters.autocompleteQuery)
+ .get(autocompleteQuery({ state, fetchType }))
.then(({ data }) => {
commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data);
})
@@ -13,6 +29,13 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => {
});
};
+export const fetchAutocompleteOptions = ({ commit, state }) => {
+ commit(types.REQUEST_AUTOCOMPLETE);
+ const promises = FETCH_TYPES.map((fetchType) => doFetch({ commit, state, fetchType }));
+
+ return Promise.all(promises);
+};
+
export const clearAutocomplete = ({ commit }) => {
commit(types.CLEAR_AUTOCOMPLETE);
};
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index da7bccd35c0..3da9d2cd961 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -14,6 +14,7 @@ import {
PROJECTS_CATEGORY,
GROUPS_CATEGORY,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
+ DROPDOWN_ORDER,
} from '../constants';
export const searchQuery = (state) => {
@@ -34,19 +35,6 @@ export const searchQuery = (state) => {
return `${state.searchPath}?${objectToQuery(query)}`;
};
-export const autocompleteQuery = (state) => {
- const query = omitBy(
- {
- term: state.search,
- project_id: state.searchContext?.project?.id,
- project_ref: state.searchContext?.ref,
- },
- isNil,
- );
-
- return `${state.autocompletePath}?${objectToQuery(query)}`;
-};
-
export const scopedIssuesPath = (state) => {
return (
state.searchContext?.project_metadata?.issues_path ||
@@ -197,7 +185,9 @@ export const autocompleteGroupedSearchOptions = (state) => {
}
});
- return results;
+ return results.sort(
+ (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category),
+ );
};
export const searchOptions = (state, getters) => {
diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js
index 92948bec515..19b4d4ec330 100644
--- a/app/assets/javascripts/header_search/store/mutations.js
+++ b/app/assets/javascripts/header_search/store/mutations.js
@@ -8,9 +8,11 @@ export default {
},
[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
state.loading = false;
- state.autocompleteOptions = data.map((d, i) => {
- return { html_id: `autocomplete-${d.category}-${i}`, ...d };
- });
+ state.autocompleteOptions = [...state.autocompleteOptions].concat(
+ data.map((d, i) => {
+ return { html_id: `autocomplete-${d.category}-${i}`, ...d };
+ }),
+ );
state.autocompleteError = false;
},
[types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
index 52593aabfea..d40aab8ee4f 100644
--- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -50,7 +50,7 @@ export default {
<gl-dropdown-item
v-for="mode in modeDropdownItems"
:key="mode.viewerType"
- :is-check-item="true"
+ is-check-item
:is-checked="viewer === mode.viewerType"
@click="changeMode(mode.viewerType)"
>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index e0b7ac9b1e1..8962bb76926 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -4,7 +4,7 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { rightSidebarViews } from '../constants';
import IdeStatusList from './ide_status_list.vue';
import IdeStatusMr from './ide_status_mr.vue';
@@ -12,7 +12,7 @@ import IdeStatusMr from './ide_status_mr.vue';
export default {
components: {
GlIcon,
- userAvatarImage,
+ UserAvatarImage,
CiIcon,
IdeStatusList,
IdeStatusMr,
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index 87b60eca73c..9a529bdcee1 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -4,12 +4,12 @@ import { mapActions } from 'vuex';
import { modalTypes } from '../../constants';
import ItemButton from './button.vue';
import NewModal from './modal.vue';
-import upload from './upload.vue';
+import Upload from './upload.vue';
export default {
components: {
GlIcon,
- upload,
+ Upload,
ItemButton,
NewModal,
},
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index d6207d4a557..9684bf8f18c 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -176,7 +176,11 @@ export default {
:placeholder="placeholder"
/>
</form>
- <ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline qa-template-list">
+ <ul
+ v-if="isCreatingNewFile"
+ class="file-templates gl-mt-3 list-inline"
+ data-qa-selector="template_list_content"
+ >
<li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item">
<gl-button
variant="dashed"
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index df643675357..10e9f6a9488 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -8,6 +8,7 @@ import { parseBoolean } from '../lib/utils/common_utils';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
import ide from './components/ide.vue';
import { createRouter } from './ide_router';
+import { initGitlabWebIDE } from './init_gitlab_web_ide';
import { DEFAULT_THEME } from './lib/themes';
import { createStore } from './stores';
@@ -34,7 +35,7 @@ Vue.use(PerformancePlugin, {
* @param {extendStoreCallback} options.extendStore -
* Function that receives the default store and returns an extended one.
*/
-export const initIde = (el, options = {}) => {
+export const initLegacyWebIDE = (el, options = {}) => {
if (!el) return null;
const { rootComponent = ide, extendStore = identity } = options;
@@ -93,8 +94,15 @@ export const initIde = (el, options = {}) => {
*/
export function startIde(options) {
const ideElement = document.getElementById('ide');
- if (ideElement) {
+
+ if (!ideElement) {
+ return;
+ }
+
+ if (gon.features?.vscodeWebIde) {
+ initGitlabWebIDE(ideElement);
+ } else {
resetServiceWorkersPublicPath();
- initIde(ideElement, options);
+ initLegacyWebIDE(ideElement, options);
}
}
diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js
new file mode 100644
index 00000000000..a061da38d4f
--- /dev/null
+++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js
@@ -0,0 +1,30 @@
+import { cleanTrailingSlash } from './stores/utils';
+
+export const initGitlabWebIDE = async (el) => {
+ const { start } = await import('@gitlab/web-ide');
+
+ const { gitlab_url: gitlabUrl } = window.gon;
+ const baseUrl = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin);
+
+ // what: Pull what we need from the element. We will replace it soon.
+ const { path_with_namespace: projectPath } = JSON.parse(el.dataset.project);
+ const { cspNonce: nonce, branchName: ref } = el.dataset;
+
+ // what: Clean up the element, but preserve id.
+ // why: This way we don't inherit any `ide-loading` side-effects. This
+ // mirrors the behavior of Vue when it mounts to an element.
+ const newEl = document.createElement(el.tagName);
+ newEl.id = el.id;
+ newEl.classList.add('gl--flex-center', 'gl-relative', 'gl-h-full');
+
+ el.replaceWith(newEl);
+
+ // what: Trigger start on our new mounting element
+ await start(newEl, {
+ baseUrl: cleanTrailingSlash(baseUrl.href),
+ projectPath,
+ gitlabUrl,
+ ref,
+ nonce,
+ });
+};
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index 5ff00394e3b..35d8ec32bdf 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -30,6 +30,7 @@ export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
export function addImageCommentBadge(containerEl, { coordinate, noteId }) {
const buttonEl = createImageBadge(noteId, coordinate, ['image-comment-badge']);
+ // eslint-disable-next-line no-unsanitized/property
buttonEl.innerHTML = spriteIcon('image-comment-dark');
containerEl.appendChild(buttonEl);
diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
index deaef686f59..2b5cb70737f 100644
--- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
@@ -8,6 +8,7 @@ export function addCommentIndicator(containerEl, { x, y }) {
buttonEl.style.left = `${x}px`;
buttonEl.style.top = `${y}px`;
+ // eslint-disable-next-line no-unsanitized/property
buttonEl.innerHTML = spriteIcon('image-comment-dark');
containerEl.appendChild(buttonEl);
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 87f1ed31a7f..a334f5e4bf7 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -118,6 +118,7 @@ export default {
selectedAccessLevel: undefined,
errorsLimit: 2,
isErrorsSectionExpanded: false,
+ emptyInvitesError: false,
};
},
computed: {
@@ -133,8 +134,8 @@ export default {
labelIntroText() {
return this.$options.labels[this.inviteTo][this.mode].introText;
},
- inviteDisabled() {
- return this.newUsersToInvite.length === 0;
+ isEmptyInvites() {
+ return Boolean(this.newUsersToInvite.length);
},
hasInvalidMembers() {
return !isEmpty(this.invalidMembers);
@@ -219,6 +220,18 @@ export default {
});
},
},
+ watch: {
+ isEmptyInvites: {
+ handler(updatedValue) {
+ // nothing to do if the invites are **still** empty and the emptyInvites were never set from submit
+ if (!updatedValue && !this.emptyInvitesError) {
+ return;
+ }
+
+ this.clearEmptyInviteError();
+ },
+ },
+ },
mounted() {
eventHub.$on('openModal', (options) => {
this.openModal(options);
@@ -260,10 +273,19 @@ export default {
const tracking = new ExperimentTracking(experimentName);
tracking.event(eventName);
},
+ showEmptyInvitesError() {
+ this.invalidFeedbackMessage = this.$options.labels.emptyInvitesErrorText;
+ this.emptyInvitesError = true;
+ },
sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
this.clearValidation();
+ if (!this.isEmptyInvites) {
+ this.showEmptyInvitesError();
+ return;
+ }
+
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const apiAddByInvite = this.isProject
@@ -338,6 +360,10 @@ export default {
this.invalidFeedbackMessage = '';
this.invalidMembers = {};
},
+ clearEmptyInviteError() {
+ this.invalidFeedbackMessage = '';
+ this.emptyInvitesError = false;
+ },
removeToken(token) {
delete this.invalidMembers[memberName(token)];
this.invalidMembers = { ...this.invalidMembers };
@@ -360,7 +386,6 @@ export default {
:label-intro-text="labelIntroText"
:label-search-field="$options.labels.searchField"
:form-group-description="formGroupDescription"
- :submit-disabled="inviteDisabled"
:invalid-feedback-message="invalidFeedbackMessage"
:is-loading="isLoading"
:new-users-to-invite="newUsersToInvite"
diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
index 6c9b1f8e6d0..c3d9d959ef6 100644
--- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -8,8 +8,6 @@ import {
REACHED_LIMIT_MESSAGE,
REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
CLOSE_TO_LIMIT_MESSAGE,
- CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE,
- DANGER_ALERT_TITLE_PERSONAL_NAMESPACE,
} from '../constants';
export default {
@@ -52,13 +50,6 @@ export default {
});
},
dangerAlertTitle() {
- if (this.usersLimitDataset.userNamespace) {
- return sprintf(DANGER_ALERT_TITLE_PERSONAL_NAMESPACE, {
- count: this.freeUsersLimit,
- members: this.pluralMembers(this.freeUsersLimit),
- });
- }
-
return sprintf(DANGER_ALERT_TITLE, {
count: this.freeUsersLimit,
members: this.pluralMembers(this.freeUsersLimit),
@@ -71,20 +62,9 @@ export default {
title() {
return this.reachedLimit ? this.dangerAlertTitle : this.warningAlertTitle;
},
- reachedLimitMessage() {
- if (this.usersLimitDataset.userNamespace) {
- return this.$options.i18n.reachedLimitMessage;
- }
-
- return this.$options.i18n.reachedLimitUpgradeSuggestionMessage;
- },
message() {
if (this.reachedLimit) {
- return this.reachedLimitMessage;
- }
-
- if (this.usersLimitDataset.userNamespace) {
- return this.$options.i18n.closeToLimitMessagePersonalNamespace;
+ return this.$options.i18n.reachedLimitUpgradeSuggestionMessage;
}
return this.$options.i18n.closeToLimitMessage;
@@ -99,7 +79,6 @@ export default {
reachedLimitMessage: REACHED_LIMIT_MESSAGE,
reachedLimitUpgradeSuggestionMessage: REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
closeToLimitMessage: CLOSE_TO_LIMIT_MESSAGE,
- closeToLimitMessagePersonalNamespace: CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE,
},
};
</script>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 1ceb63e2146..f502e1ea369 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -81,6 +81,9 @@ export const MEMBER_ERROR_LIST_TEXT = s__(
);
export const COLLAPSED_ERRORS = s__('InviteMembersModal|Show more (%{count})');
export const EXPANDED_ERRORS = s__('InviteMembersModal|Show less');
+export const EMPTY_INVITES_ERROR_TEXT = s__(
+ 'InviteMembersModal|Please select members or type email addresses to invite',
+);
export const MEMBER_MODAL_LABELS = {
modal: {
@@ -119,6 +122,7 @@ export const MEMBER_MODAL_LABELS = {
memberErrorListText: MEMBER_ERROR_LIST_TEXT,
collapsedErrors: COLLAPSED_ERRORS,
expandedErrors: EXPANDED_ERRORS,
+ emptyInvitesErrorText: EMPTY_INVITES_ERROR_TEXT,
};
export const GROUP_MODAL_LABELS = {
@@ -146,10 +150,6 @@ export const DANGER_ALERT_TITLE = s__(
"InviteMembersModal|You've reached your %{count} %{members} limit for %{name}",
);
-export const DANGER_ALERT_TITLE_PERSONAL_NAMESPACE = s__(
- "InviteMembersModal|You've reached your %{count} %{members} limit for your personal projects",
-);
-
export const REACHED_LIMIT_MESSAGE = s__(
'InviteMembersModal|You cannot add more members, but you can remove members who no longer need access.',
);
@@ -163,6 +163,3 @@ export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.co
export const CLOSE_TO_LIMIT_MESSAGE = s__(
'InviteMembersModal|To get more members an owner of the group can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
);
-export const CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE = s__(
- 'InviteMembersModal|To make more space, you can remove members who no longer need access.',
-);
diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
index 6e2c0ecb5bb..a4be3f205a3 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -20,8 +20,6 @@ export default (function initInviteMembersModal() {
return false;
}
- const usersLimitDataset = JSON.parse(el.dataset.usersLimitDataset || '{}');
-
inviteMembersModal = new Vue({
el,
name: 'InviteMembersModalRoot',
@@ -40,10 +38,9 @@ export default (function initInviteMembersModal() {
projects: JSON.parse(el.dataset.projects || '[]'),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
- usersLimitDataset: convertObjectPropsToCamelCase({
- ...usersLimitDataset,
- user_namespace: parseBoolean(usersLimitDataset.user_namespace),
- }),
+ usersLimitDataset: convertObjectPropsToCamelCase(
+ JSON.parse(el.dataset.usersLimitDataset || '{}'),
+ ),
},
}),
});
diff --git a/app/assets/javascripts/issuable/components/issue_assignees.vue b/app/assets/javascripts/issuable/components/issue_assignees.vue
index 5955f31fc70..21f35690f6d 100644
--- a/app/assets/javascripts/issuable/components/issue_assignees.vue
+++ b/app/assets/javascripts/issuable/components/issue_assignees.vue
@@ -91,7 +91,7 @@ export default {
data-qa-selector="assignee_link"
>
<span class="js-assignee-tooltip">
- <span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }}
+ <span class="bold d-block">{{ s__('Label|Assignee') }}</span> {{ assignee.name }}
<span v-if="assignee.username" class="text-white-50">@{{ assignee.username }}</span>
</span>
</user-avatar-link>
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index 667c712d3be..8894e8f63b8 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -11,6 +11,7 @@ import {
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { isMetaKey } from '~/lib/utils/common_utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -80,6 +81,9 @@ export default {
methods: {
handleTitleClick(event) {
if (this.workItemType === 'TASK') {
+ if (isMetaKey(event)) {
+ return;
+ }
event.preventDefault();
this.$refs.modal.show();
this.updateWorkItemIdUrlQuery(this.idKey);
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
index d72ee5c6757..6c4ffc44444 100644
--- a/app/assets/javascripts/issuable/components/status_box.vue
+++ b/app/assets/javascripts/issuable/components/status_box.vue
@@ -65,7 +65,7 @@ export default {
data() {
if (!this.iid) return { state: this.initialState };
- if (this.initialState) {
+ if (this.initialState && !badgeState.state) {
badgeState.state = this.initialState;
}
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index cc2608b5c62..81bf7ca6ccc 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -39,12 +39,26 @@ function format(searchTerm, isFallbackKey = false) {
return formattedQuery;
}
+function getSearchTerm(newIssuePath) {
+ const { search, pathname } = document.location;
+ return newIssuePath === pathname ? '' : format(search);
+}
+
function getFallbackKey() {
const searchTerm = format(document.location.search, true);
return ['autosave', document.location.pathname, searchTerm].join('/');
}
export default class IssuableForm {
+ static addAutosave(map, id, $input, searchTerm, fallbackKey) {
+ if ($input.length) {
+ map.set(
+ id,
+ new Autosave($input, [document.location.pathname, searchTerm, id], `${fallbackKey}=${id}`),
+ );
+ }
+ }
+
constructor(form) {
if (form.length === 0) {
return;
@@ -72,14 +86,15 @@ export default class IssuableForm {
this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search');
this.zenMode = new ZenMode();
- this.newIssuePath = form[0].getAttribute(DATA_ISSUES_NEW_PATH);
+ this.searchTerm = getSearchTerm(form[0].getAttribute(DATA_ISSUES_NEW_PATH));
+ this.fallbackKey = getFallbackKey();
this.titleField = this.form.find('input[name*="[title]"]');
this.descriptionField = this.form.find('textarea[name*="[description]"]');
if (!(this.titleField.length && this.descriptionField.length)) {
return;
}
- this.initAutosave();
+ this.autosaves = this.initAutosave();
this.form.on('submit', this.handleSubmit);
this.form.on('click', '.btn-cancel, .js-reset-autosave', this.resetAutosave);
this.form.find('.js-unwrap-on-load').unwrap();
@@ -95,7 +110,10 @@ export default class IssuableForm {
container: $issuableDueDate.parent().get(0),
parse: (dateString) => parsePikadayDate(dateString),
toString: (date) => pikadayToString(date),
- onSelect: (dateText) => $issuableDueDate.val(calendar.toString(dateText)),
+ onSelect: (dateText) => {
+ $issuableDueDate.val(calendar.toString(dateText));
+ if (this.autosaves.has('due_date')) this.autosaves.get('due_date').save();
+ },
firstDay: gon.first_day_of_week,
});
calendar.setDate(parsePikadayDate($issuableDueDate.val()));
@@ -109,21 +127,37 @@ export default class IssuableForm {
}
initAutosave() {
- const { search, pathname } = document.location;
- const searchTerm = this.newIssuePath === pathname ? '' : format(search);
- const fallbackKey = getFallbackKey();
-
- this.autosave = new Autosave(
- this.titleField,
- [document.location.pathname, searchTerm, 'title'],
- `${fallbackKey}=title`,
+ const autosaveMap = new Map();
+ IssuableForm.addAutosave(
+ autosaveMap,
+ 'title',
+ this.form.find('input[name*="[title]"]'),
+ this.searchTerm,
+ this.fallbackKey,
);
-
- return new Autosave(
- this.descriptionField,
- [document.location.pathname, searchTerm, 'description'],
- `${fallbackKey}=description`,
+ IssuableForm.addAutosave(
+ autosaveMap,
+ 'description',
+ this.form.find('textarea[name*="[description]"]'),
+ this.searchTerm,
+ this.fallbackKey,
+ );
+ IssuableForm.addAutosave(
+ autosaveMap,
+ 'confidential',
+ this.form.find('input:checkbox[name*="[confidential]"]'),
+ this.searchTerm,
+ this.fallbackKey,
);
+ IssuableForm.addAutosave(
+ autosaveMap,
+ 'due_date',
+ this.form.find('input[name*="[due_date]"]'),
+ this.searchTerm,
+ this.fallbackKey,
+ );
+
+ return autosaveMap;
}
handleSubmit() {
@@ -131,8 +165,9 @@ export default class IssuableForm {
}
resetAutosave() {
- this.titleField.data('autosave').reset();
- return this.descriptionField.data('autosave').reset();
+ this.autosaves.forEach((autosaveItem) => {
+ autosaveItem?.reset();
+ });
}
initWip() {
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 11911adb401..0b424d105b9 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -24,6 +24,7 @@ import axios from '~/lib/utils/axios_utils';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
+import { helpPagePath } from '~/helpers/help_page_helper';
import {
DEFAULT_NONE_ANY,
OPERATOR_IS_ONLY,
@@ -37,6 +38,7 @@ import {
TOKEN_TITLE_ORGANIZATION,
TOKEN_TITLE_RELEASE,
TOKEN_TITLE_TYPE,
+ FILTERED_SEARCH_TERM,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
@@ -462,6 +464,12 @@ export default {
page_before: this.pageParams.beforeCursor ?? undefined,
};
},
+ issuesHelpPagePath() {
+ return helpPagePath('user/project/issues/index');
+ },
+ shouldDisableSomeFilters() {
+ return this.isAnonymousSearchDisabled && !this.isSignedIn;
+ },
},
watch: {
$route(newValue, oldValue) {
@@ -578,13 +586,9 @@ export default {
this.issuesError = null;
},
handleFilter(filter) {
- if (this.isAnonymousSearchDisabled && !this.isSignedIn) {
- this.showAnonymousSearchingMessage();
- return;
- }
+ this.setFilterTokens(filter);
this.pageParams = getInitialPageParams(this.pageSize);
- this.filterTokens = filter;
this.$router.push({ query: this.urlParams });
},
@@ -674,6 +678,28 @@ export default {
Sentry.captureException(error);
});
},
+ setFilterTokens(filtersArg) {
+ const filters = this.removeDisabledSearchTerms(filtersArg);
+
+ this.filterTokens = filters;
+
+ // If we filtered something out, let's show a warning message
+ if (filters.length < filtersArg.length) {
+ this.showAnonymousSearchingMessage();
+ }
+ },
+ removeDisabledSearchTerms(filters) {
+ // If we shouldn't disable anything, let's return the same thing
+ if (!this.shouldDisableSomeFilters) {
+ return filters;
+ }
+
+ const filtersWithoutSearchTerms = filters.filter(
+ (token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data),
+ );
+
+ return filtersWithoutSearchTerms;
+ },
showAnonymousSearchingMessage() {
createFlash({
message: this.$options.i18n.anonymousSearchingMessage,
@@ -720,17 +746,9 @@ export default {
sortKey = defaultSortKey;
}
- const isSearchDisabled =
- this.isAnonymousSearchDisabled &&
- !this.isSignedIn &&
- window.location.search.includes('search=');
-
- if (isSearchDisabled) {
- this.showAnonymousSearchingMessage();
- }
-
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
- this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search);
+ this.setFilterTokens(getFilterTokens(window.location.search));
+
this.pageParams = getInitialPageParams(
this.pageSize,
isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined,
@@ -899,7 +917,9 @@ export default {
<template v-else-if="isSignedIn">
<gl-empty-state :title="$options.i18n.noIssuesSignedInTitle" :svg-path="emptyStateSvgPath">
<template #description>
- <p>{{ $options.i18n.noIssuesSignedInDescription }}</p>
+ <gl-link :href="issuesHelpPagePath" target="_blank">{{
+ $options.i18n.noIssuesSignedInDescription
+ }}</gl-link>
<p v-if="canCreateProjects">
<strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong>
</p>
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 38fe4c33792..27738d7a3e6 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -41,12 +41,8 @@ export const i18n = {
),
noOpenIssuesDescription: __('To keep this project going, create a new issue'),
noOpenIssuesTitle: __('There are no open issues'),
- noIssuesSignedInDescription: __(
- 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.',
- ),
- noIssuesSignedInTitle: __(
- 'The Issue Tracker is the place to add things that need to be improved or solved in a project',
- ),
+ noIssuesSignedInDescription: __('Learn more about issues.'),
+ noIssuesSignedInTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'),
noIssuesSignedOutButtonText: __('Register / Sign In'),
noIssuesSignedOutDescription: __(
'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
@@ -151,6 +147,7 @@ export const TOKEN_TYPE_EPIC = 'epic_id';
export const TOKEN_TYPE_WEIGHT = 'weight';
export const TOKEN_TYPE_CONTACT = 'crm_contact';
export const TOKEN_TYPE_ORGANIZATION = 'crm_organization';
+export const TOKEN_TYPE_HEALTH = 'health_status';
export const TYPE_TOKEN_TASK_OPTION = { icon: 'task-done', title: 'task', value: 'task' };
@@ -327,6 +324,16 @@ export const filters = {
},
},
},
+ [TOKEN_TYPE_HEALTH]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'healthStatus',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'health_status',
+ },
+ },
+ },
[TOKEN_TYPE_CONTACT]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'crmContactId',
diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
index a5cba3daafa..149049247fb 100644
--- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
@@ -65,7 +65,7 @@ export default {
<template>
<div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
- <div class="card card-slim gl-mt-5">
+ <div class="card card-slim gl-mt-5 gl-mb-0">
<div class="card-header gl-bg-gray-10">
<div
class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0"
@@ -112,7 +112,7 @@ export default {
</div>
<div
v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
- class="issue-closed-by-widget second-block"
+ class="issue-closed-by-widget second-block gl-mt-3"
>
{{ closingMergeRequestsText }}
</div>
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index c664135f30e..0daf77e03dc 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -17,11 +17,11 @@ import eventHub from '../event_hub';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
import Service from '../services/index';
import Store from '../stores';
-import descriptionComponent from './description.vue';
-import editedComponent from './edited.vue';
-import formComponent from './form.vue';
+import DescriptionComponent from './description.vue';
+import EditedComponent from './edited.vue';
+import FormComponent from './form.vue';
import PinnedLinks from './pinned_links.vue';
-import titleComponent from './title.vue';
+import TitleComponent from './title.vue';
export default {
WorkspaceType,
@@ -29,9 +29,9 @@ export default {
GlIcon,
GlBadge,
GlIntersectionObserver,
- titleComponent,
- editedComponent,
- formComponent,
+ TitleComponent,
+ EditedComponent,
+ FormComponent,
PinnedLinks,
ConfidentialityBadge,
},
@@ -51,20 +51,11 @@ export default {
required: true,
type: Boolean,
},
- canDestroy: {
- required: true,
- type: Boolean,
- },
showInlineEditButton: {
type: Boolean,
required: false,
default: true,
},
- showDeleteButton: {
- type: Boolean,
- required: false,
- default: true,
- },
enableAutocomplete: {
type: Boolean,
required: false,
@@ -181,7 +172,7 @@ export default {
type: Object,
required: false,
default: () => {
- return descriptionComponent;
+ return DescriptionComponent;
},
},
showTitleBorder: {
@@ -494,14 +485,12 @@ export default {
:endpoint="endpoint"
:form-state="formState"
:initial-description-text="initialDescriptionText"
- :can-destroy="canDestroy"
:issuable-templates="issuableTemplates"
:markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath"
:project-path="projectPath"
:project-id="projectId"
:project-namespace="projectNamespace"
- :show-delete-button="showDeleteButton"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
:issuable-type="issuableType"
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 47b09bd6aa0..f86ee11e64b 100644
--- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
+++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
@@ -13,7 +13,8 @@ export default {
props: {
issuePath: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
issueType: {
type: String,
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index a6747d67611..5c2a154362f 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -7,6 +7,7 @@ import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import createFlash from '~/flash';
import { IssuableType } from '~/issues/constants';
+import { isMetaKey } from '~/lib/utils/common_utils';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
@@ -20,6 +21,8 @@ import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_ite
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import {
+ sprintfWorkItem,
+ I18N_WORK_ITEM_ERROR_CREATING,
TRACKING_CATEGORY_SHOW,
TASK_TYPE_NAME,
WIDGET_TYPE_DESCRIPTION,
@@ -226,6 +229,7 @@ export default {
},
createDragIconElement() {
const container = document.createElement('div');
+ // eslint-disable-next-line no-unsanitized/property
container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-visibility-hidden" role="img" aria-hidden="true">
<use href="${gon.sprite_icons}#drag-vertical"></use>
</svg>`;
@@ -330,6 +334,9 @@ export default {
this.addHoverListeners(taskLink, workItemId);
taskLink.classList.add('gl-link');
taskLink.addEventListener('click', (e) => {
+ if (isMetaKey(e)) {
+ return;
+ }
e.preventDefault();
this.openWorkItemDetailModal(taskLink);
this.workItemId = workItemId;
@@ -358,6 +365,7 @@ export default {
);
button.id = `js-task-button-${index}`;
this.taskButtons.push(button.id);
+ // eslint-disable-next-line no-unsanitized/property
button.innerHTML = `
<svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14">
<use href="${gon.sprite_icons}#doc-new"></use>
@@ -460,7 +468,7 @@ export default {
this.openWorkItemDetailModal(el);
} catch (error) {
createFlash({
- message: s__('WorkItem|Something went wrong when creating a work item. Please try again'),
+ message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK),
error,
captureError: true,
});
diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue
index 358b53bd131..120034b8d67 100644
--- a/app/assets/javascripts/issues/show/components/edit_actions.vue
+++ b/app/assets/javascripts/issues/show/components/edit_actions.vue
@@ -1,12 +1,10 @@
<script>
-import { GlButton, GlModalDirective } from '@gitlab/ui';
-import { uniqueId } from 'lodash';
-import { __, sprintf } from '~/locale';
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
import Tracking from '~/tracking';
import eventHub from '../event_hub';
import updateMixin from '../mixins/update';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
-import DeleteIssueModal from './delete_issue_modal.vue';
const issuableTypes = {
issue: __('Issue'),
@@ -18,18 +16,10 @@ const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
export default {
components: {
- DeleteIssueModal,
GlButton,
},
- directives: {
- GlModal: GlModalDirective,
- },
mixins: [trackingMixin, updateMixin],
props: {
- canDestroy: {
- type: Boolean,
- required: true,
- },
endpoint: {
required: true,
type: String,
@@ -38,11 +28,6 @@ export default {
type: Object,
required: true,
},
- showDeleteButton: {
- type: Boolean,
- required: false,
- default: true,
- },
issuableType: {
type: String,
required: true,
@@ -53,7 +38,6 @@ export default {
deleteLoading: false,
skipApollo: false,
issueState: {},
- modalId: uniqueId('delete-issuable-modal-'),
};
},
apollo: {
@@ -68,17 +52,9 @@ export default {
},
},
computed: {
- deleteIssuableButtonText() {
- return sprintf(__('Delete %{issuableType}'), {
- issuableType: this.typeToShow.toLowerCase(),
- });
- },
isSubmitEnabled() {
return this.formState.title.trim() !== '';
},
- shouldShowDeleteButton() {
- return this.canDestroy && this.showDeleteButton && this.typeToShow;
- },
typeToShow() {
const { issueState, issuableType } = this;
const type = issueState.issueType ?? issuableType;
@@ -89,52 +65,26 @@ export default {
closeForm() {
eventHub.$emit('close.form');
},
- deleteIssuable() {
- this.deleteLoading = true;
- eventHub.$emit('delete.issuable');
- },
},
};
</script>
<template>
- <div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between">
- <div>
- <gl-button
- :loading="formState.updateLoading"
- :disabled="formState.updateLoading || !isSubmitEnabled"
- category="primary"
- variant="confirm"
- class="gl-mr-3"
- data-testid="issuable-save-button"
- type="submit"
- @click.prevent="updateIssuable"
- >
- {{ __('Save changes') }}
- </gl-button>
- <gl-button data-testid="issuable-cancel-button" @click="closeForm">
- {{ __('Cancel') }}
- </gl-button>
- </div>
- <div v-if="shouldShowDeleteButton">
- <gl-button
- v-gl-modal="modalId"
- :loading="deleteLoading"
- :disabled="deleteLoading"
- category="secondary"
- variant="danger"
- data-testid="issuable-delete-button"
- @click="track('click_button')"
- >
- {{ deleteIssuableButtonText }}
- </gl-button>
- <delete-issue-modal
- :issue-path="endpoint"
- :issue-type="typeToShow"
- :modal-id="modalId"
- :title="deleteIssuableButtonText"
- @delete="deleteIssuable"
- />
- </div>
+ <div class="gl-mt-3 gl-mb-3 gl-display-flex">
+ <gl-button
+ :loading="formState.updateLoading"
+ :disabled="formState.updateLoading || !isSubmitEnabled"
+ category="primary"
+ variant="confirm"
+ class="gl-mr-3"
+ data-testid="issuable-save-button"
+ type="submit"
+ @click.prevent="updateIssuable"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ <gl-button data-testid="issuable-cancel-button" @click="closeForm">
+ {{ __('Cancel') }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue
index 41cc3964055..4c5f783cd66 100644
--- a/app/assets/javascripts/issues/show/components/edited.vue
+++ b/app/assets/javascripts/issues/show/components/edited.vue
@@ -1,10 +1,10 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
-import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
- timeAgoTooltip,
+ TimeAgoTooltip,
},
props: {
updatedAt: {
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index f45af47374a..c2ab7c4f298 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -1,11 +1,11 @@
<script>
-import markdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import updateMixin from '../../mixins/update';
export default {
components: {
- markdownField,
+ MarkdownField,
},
mixins: [updateMixin],
props: {
diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index e2c12edf46d..f479c8ae78d 100644
--- a/app/assets/javascripts/issues/show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -22,10 +22,6 @@ export default {
LockedWarning,
},
props: {
- canDestroy: {
- type: Boolean,
- required: true,
- },
endpoint: {
type: String,
required: true,
@@ -63,11 +59,6 @@ export default {
type: String,
required: true,
},
- showDeleteButton: {
- type: Boolean,
- required: false,
- default: true,
- },
canAttachFile: {
type: Boolean,
required: false,
@@ -231,12 +222,6 @@ export default {
:enable-autocomplete="enableAutocomplete"
/>
- <edit-actions
- :endpoint="endpoint"
- :form-state="formState"
- :can-destroy="canDestroy"
- :show-delete-button="showDeleteButton"
- :issuable-type="issuableType"
- />
+ <edit-actions :endpoint="endpoint" :form-state="formState" :issuable-type="issuableType" />
</form>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js
index 77d13fe085a..aa7b9805b5f 100644
--- a/app/assets/javascripts/issues/show/components/incidents/constants.js
+++ b/app/assets/javascripts/issues/show/components/incidents/constants.js
@@ -26,4 +26,15 @@ export const timelineListI18n = Object.freeze({
'Incident|Something went wrong while deleting the incident timeline event.',
),
deleteModal: s__('Incident|Are you sure you want to delete this event?'),
+ editError: s__('Incident|Error updating incident timeline event: %{error}'),
+ editErrorGeneric: s__(
+ 'Incident|Something went wrong while updating the incident timeline event.',
+ ),
+});
+
+export const timelineItemI18n = Object.freeze({
+ delete: __('Delete'),
+ edit: __('Edit'),
+ moreActions: __('More actions'),
+ timeUTC: __('%{time} UTC'),
});
diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
index c902895702e..6bb72e82778 100644
--- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
@@ -1,6 +1,7 @@
<script>
import { produce } from 'immer';
import { sortBy } from 'lodash';
+import { GlIcon } from '@gitlab/ui';
import { sprintf } from '~/locale';
import { createAlert } from '~/flash';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -16,6 +17,7 @@ export default {
i18n: timelineFormI18n,
components: {
TimelineEventsForm,
+ GlIcon,
},
inject: ['fullPath', 'issuableId'],
props: {
@@ -31,9 +33,6 @@ export default {
clearForm() {
this.$refs.eventForm.clear();
},
- focusDate() {
- this.$refs.eventForm.focusDate();
- },
updateCache(store, { data }) {
const { timelineEvent: event, errors } = data?.timelineEventCreate || {};
@@ -107,11 +106,23 @@ export default {
</script>
<template>
- <timeline-events-form
- ref="eventForm"
- :is-event-processed="createTimelineEventActive"
- :has-timeline-events="hasTimelineEvents"
- @save-event="createIncidentTimelineEvent"
- @cancel="$emit('hide-new-timeline-events-form')"
- />
+ <div
+ class="create-timeline-event gl-relative gl-display-flex gl-align-items-start"
+ :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }"
+ >
+ <div
+ v-if="hasTimelineEvents"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
+ >
+ <gl-icon name="comment" class="note-icon" />
+ </div>
+ <timeline-events-form
+ ref="eventForm"
+ :class="{ 'gl-border-gray-50 gl-border-t': hasTimelineEvents }"
+ :is-event-processed="createTimelineEventActive"
+ show-save-and-add
+ @save-event="createIncidentTimelineEvent"
+ @cancel="$emit('hide-new-timeline-events-form')"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue
new file mode 100644
index 00000000000..60fa8cb949b
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import TimelineEventsForm from './timeline_events_form.vue';
+
+export default {
+ name: 'EditTimelineEvent',
+ components: {
+ TimelineEventsForm,
+ GlIcon,
+ },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ validator: (item) => ['occurredAt', 'note'].every((key) => item[key]),
+ },
+ editTimelineEventActive: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ saveEvent(eventDetails) {
+ this.$emit('handle-save-edit', { ...eventDetails, id: this.event.id }, false);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-relative gl-display-flex gl-align-items-center">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
+ >
+ <gl-icon name="comment" class="note-icon" />
+ </div>
+ <timeline-events-form
+ ref="eventForm"
+ class="timeline-event-border"
+ :is-event-processed="editTimelineEventActive"
+ :previous-occurred-at="event.occurredAt"
+ :previous-note="event.note"
+ @save-event="saveEvent"
+ @cancel="$emit('hide-edit')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql
new file mode 100644
index 00000000000..54f036268cc
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql
@@ -0,0 +1,13 @@
+mutation UpdateTimelineEvent($input: TimelineEventUpdateInput!) {
+ timelineEventUpdate(input: $input) {
+ timelineEvent {
+ id
+ note
+ noteHtml
+ action
+ occurredAt
+ createdAt
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
index 0d84fabb1be..b7ae18372ab 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
@@ -1,9 +1,9 @@
<script>
-import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui';
+import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { timelineFormI18n } from './constants';
-import { getUtcShiftedDateNow } from './utils';
+import { getUtcShiftedDate } from './utils';
export default {
name: 'TimelineEventsForm',
@@ -15,6 +15,7 @@ export default {
'task-list',
'collapsible-section',
'table',
+ 'attach-file',
'full-screen',
],
components: {
@@ -23,175 +24,168 @@ export default {
GlFormInput,
GlFormGroup,
GlButton,
- GlIcon,
},
i18n: timelineFormI18n,
directives: {
autofocusonshow,
},
props: {
- hasTimelineEvents: {
+ showSaveAndAdd: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
isEventProcessed: {
type: Boolean,
required: true,
},
+ previousOccurredAt: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ previousNote: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
- // if occurredAt is undefined, returns "now" in UTC
- const placeholderDate = getUtcShiftedDateNow();
+ // if occurredAt is null, returns "now" in UTC
+ const placeholderDate = getUtcShiftedDate(this.previousOccurredAt);
return {
- timelineText: '',
+ timelineText: this.previousNote,
placeholderDate,
hourPickerInput: placeholderDate.getHours(),
minutePickerInput: placeholderDate.getMinutes(),
- datepickerTextInput: null,
+ datePickerInput: placeholderDate,
};
},
computed: {
- occurredAt() {
- const [years, months, days] = this.datepickerTextInput.split('-');
+ occurredAtString() {
+ const year = this.datePickerInput.getFullYear();
+ const month = this.datePickerInput.getMonth();
+ const day = this.datePickerInput.getDate();
+
const utcDate = new Date(
- Date.UTC(years, months - 1, days, this.hourPickerInput, this.minutePickerInput),
+ Date.UTC(year, month, day, this.hourPickerInput, this.minutePickerInput),
);
return utcDate.toISOString();
},
},
+ mounted() {
+ this.focusDate();
+ },
methods: {
clear() {
- const utcShiftedDateNow = getUtcShiftedDateNow();
- this.placeholderDate = utcShiftedDateNow;
- this.hourPickerInput = utcShiftedDateNow.getHours();
- this.minutePickerInput = utcShiftedDateNow.getMinutes();
+ const newPlaceholderDate = getUtcShiftedDate();
+ this.datePickerInput = newPlaceholderDate;
+ this.hourPickerInput = newPlaceholderDate.getHours();
+ this.minutePickerInput = newPlaceholderDate.getMinutes();
this.timelineText = '';
},
focusDate() {
- this.$refs.datepicker.$el.focus();
+ this.$refs.datepicker.$el.querySelector('input').focus();
},
handleSave(addAnotherEvent) {
- const eventDetails = {
+ const event = {
note: this.timelineText,
- occurredAt: this.occurredAt,
+ occurredAt: this.occurredAtString,
};
- this.$emit('save-event', eventDetails, addAnotherEvent);
+ this.$emit('save-event', event, addAnotherEvent);
},
},
};
</script>
<template>
- <div
- class="gl-relative gl-display-flex gl-align-items-center"
- :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }"
- >
- <div
- v-if="hasTimelineEvents"
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
- >
- <gl-icon name="comment" class="note-icon" />
- </div>
- <form class="gl-flex-grow-1 gl-border-gray-50" :class="{ 'gl-border-t': hasTimelineEvents }">
- <div
- class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker"
- >
- <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5">
- <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="placeholderDate">
+ <form class="gl-flex-grow-1 gl-border-gray-50">
+ <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row">
+ <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5">
+ <gl-datepicker id="incident-date" ref="datepicker" v-model="datePickerInput" />
+ </gl-form-group>
+ <div class="gl-display-flex gl-mt-5">
+ <gl-form-group :label="__('Time')">
+ <div class="gl-display-flex">
+ <label label-for="timeline-input-hours" class="sr-only"></label>
<gl-form-input
- id="incident-date"
- ref="datepicker"
- v-model="datepickerTextInput"
- data-testid="input-datepicker"
- class="gl-datepicker-input gl-pr-7!"
- :value="formattedDate"
- :placeholder="__('YYYY-MM-DD')"
- @keydown.enter="onKeydown"
+ id="timeline-input-hours"
+ v-model="hourPickerInput"
+ data-testid="input-hours"
+ size="xs"
+ type="number"
+ min="00"
+ max="23"
/>
- </gl-datepicker>
- </gl-form-group>
- <div class="gl-display-flex gl-mt-5">
- <gl-form-group :label="__('Time')">
- <div class="gl-display-flex">
- <label label-for="timeline-input-hours" class="sr-only"></label>
- <gl-form-input
- id="timeline-input-hours"
- v-model="hourPickerInput"
- data-testid="input-hours"
- size="xs"
- type="number"
- min="00"
- max="23"
- />
- <label label-for="timeline-input-minutes" class="sr-only"></label>
- <gl-form-input
- id="timeline-input-minutes"
- v-model="minutePickerInput"
- class="gl-ml-3"
- data-testid="input-minutes"
- size="xs"
- type="number"
- min="00"
- max="59"
- />
- </div>
- </gl-form-group>
- <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
- </div>
- </div>
- <div class="common-note-form">
- <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel">
- <markdown-field
- :can-attach-file="false"
- :add-spacing-classes="false"
- :show-comment-tool-bar="false"
- :textarea-value="timelineText"
- :restricted-tool-bar-items="$options.restrictedToolBarItems"
- markdown-docs-path=""
- :enable-preview="false"
- class="bordered-box gl-mt-0"
- >
- <template #textarea>
- <textarea
- v-model="timelineText"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- data-testid="input-note"
- dir="auto"
- data-supports-quick-actions="false"
- :aria-label="$options.i18n.description"
- :placeholder="$options.i18n.areaPlaceholder"
- >
- </textarea>
- </template>
- </markdown-field>
+ <label label-for="timeline-input-minutes" class="sr-only"></label>
+ <gl-form-input
+ id="timeline-input-minutes"
+ v-model="minutePickerInput"
+ class="gl-ml-3"
+ data-testid="input-minutes"
+ size="xs"
+ type="number"
+ min="00"
+ max="59"
+ />
+ </div>
</gl-form-group>
+ <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
</div>
- <gl-form-group class="gl-mb-0">
- <gl-button
- variant="confirm"
- category="primary"
- class="gl-mr-3"
- :loading="isEventProcessed"
- @click="handleSave(false)"
- >
- {{ $options.i18n.save }}
- </gl-button>
- <gl-button
- variant="confirm"
- category="secondary"
- class="gl-mr-3 gl-ml-n2"
- :loading="isEventProcessed"
- @click="handleSave(true)"
+ </div>
+ <div class="common-note-form">
+ <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel">
+ <markdown-field
+ :can-attach-file="false"
+ :add-spacing-classes="false"
+ :show-comment-tool-bar="false"
+ :textarea-value="timelineText"
+ :restricted-tool-bar-items="$options.restrictedToolBarItems"
+ markdown-docs-path=""
+ :enable-preview="false"
+ class="bordered-box gl-mt-0"
>
- {{ $options.i18n.saveAndAdd }}
- </gl-button>
- <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')">
- {{ $options.i18n.cancel }}
- </gl-button>
- <div class="gl-border-b gl-pt-5"></div>
+ <template #textarea>
+ <textarea
+ v-model="timelineText"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-testid="input-note"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="$options.i18n.description"
+ :placeholder="$options.i18n.areaPlaceholder"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
</gl-form-group>
- </form>
- </div>
+ </div>
+ <gl-form-group class="gl-mb-0">
+ <gl-button
+ variant="confirm"
+ category="primary"
+ class="gl-mr-3"
+ :loading="isEventProcessed"
+ @click="handleSave(false)"
+ >
+ {{ $options.i18n.save }}
+ </gl-button>
+ <gl-button
+ v-if="showSaveAndAdd"
+ variant="confirm"
+ category="secondary"
+ class="gl-mr-3 gl-ml-n2"
+ :loading="isEventProcessed"
+ @click="handleSave(true)"
+ >
+ {{ $options.i18n.saveAndAdd }}
+ </gl-button>
+ <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ <div class="timeline-event-bottom-border"></div>
+ </gl-form-group>
+ </form>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
index 6175c9969ec..cbf3c387fa3 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
@@ -1,25 +1,13 @@
<script>
-import {
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
- GlSafeHtmlDirective,
- GlSprintf,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
-import { __ } from '~/locale';
+import { timelineItemI18n } from './constants';
import { getEventIcon } from './utils';
export default {
name: 'IncidentTimelineEventListItem',
- i18n: {
- delete: __('Delete'),
- moreActions: __('More actions'),
- timeUTC: __('%{time} UTC'),
- },
+ i18n: timelineItemI18n,
components: {
- GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
@@ -28,12 +16,8 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
- inject: ['canUpdate'],
+ inject: ['canUpdateTimelineEvent'],
props: {
- isLastItem: {
- type: Boolean,
- required: true,
- },
occurredAt: {
type: String,
required: true,
@@ -58,43 +42,41 @@ export default {
};
</script>
<template>
- <li
- class="timeline-entry timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!"
- >
- <div class="gl-display-flex gl-align-items-center">
- <div
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-n2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1"
- >
- <gl-icon :name="getEventIcon(action)" class="note-icon" />
+ <div class="gl-display-flex gl-align-items-start">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1"
+ >
+ <gl-icon :name="getEventIcon(action)" class="note-icon" />
+ </div>
+ <div
+ class="timeline-event-note timeline-event-border gl-w-full gl-display-flex gl-flex-direction-row"
+ data-testid="event-text-container"
+ >
+ <div>
+ <strong class="gl-font-lg" data-testid="event-time">
+ <gl-sprintf :message="$options.i18n.timeUTC">
+ <template #time>{{ time }}</template>
+ </gl-sprintf>
+ </strong>
+ <div v-safe-html="noteHtml"></div>
</div>
- <div
- class="timeline-event-note gl-w-full gl-display-flex gl-flex-direction-row"
- :class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }"
- data-testid="event-text-container"
+ <gl-dropdown
+ v-if="canUpdateTimelineEvent"
+ right
+ class="event-note-actions gl-ml-auto gl-align-self-start"
+ icon="ellipsis_v"
+ text-sr-only
+ :text="$options.i18n.moreActions"
+ category="tertiary"
+ no-caret
>
- <div>
- <strong class="gl-font-lg" data-testid="event-time">
- <gl-sprintf :message="$options.i18n.timeUTC">
- <template #time>{{ time }}</template>
- </gl-sprintf>
- </strong>
- <div v-safe-html="noteHtml"></div>
- </div>
- <gl-dropdown
- v-if="canUpdate"
- right
- class="event-note-actions gl-ml-auto gl-align-self-center"
- icon="ellipsis_v"
- text-sr-only
- :text="$options.i18n.moreActions"
- category="tertiary"
- no-caret
- >
- <gl-dropdown-item @click="$emit('delete')">
- <gl-button>{{ $options.i18n.delete }}</gl-button>
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
+ <gl-dropdown-item @click="$emit('edit')">
+ {{ $options.i18n.edit }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="$emit('delete')">
+ {{ $options.i18n.delete }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
- </li>
+ </div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
index 80ac1c372cd..321b7ccc14a 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
@@ -5,7 +5,9 @@ import { sprintf } from '~/locale';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import IncidentTimelineEventItem from './timeline_events_item.vue';
+import EditTimelineEvent from './edit_timeline_event.vue';
import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql';
+import editTimelineEvent from './graphql/queries/edit_timeline_event.mutation.graphql';
import { timelineListI18n } from './constants';
export default {
@@ -13,6 +15,7 @@ export default {
i18n: timelineListI18n,
components: {
IncidentTimelineEventItem,
+ EditTimelineEvent,
},
props: {
timelineEventLoading: {
@@ -26,6 +29,9 @@ export default {
default: () => [],
},
},
+ data() {
+ return { eventToEdit: null, editTimelineEventActive: false };
+ },
computed: {
dateGroupedEvents() {
const groupedEvents = new Map();
@@ -44,11 +50,12 @@ export default {
},
},
methods: {
- isLastItem(groups, groupIndex, events, eventIndex) {
- if (groupIndex < groups.size - 1) {
- return false;
- }
- return eventIndex === events.length - 1;
+ handleEditSelection(event) {
+ this.eventToEdit = event.id;
+ this.$emit('hide-new-incident-timeline-event-form');
+ },
+ hideEdit() {
+ this.eventToEdit = null;
},
handleDelete: ignoreWhilePending(async function handleDelete(event) {
const msg = this.$options.i18n.deleteModal;
@@ -85,6 +92,38 @@ export default {
createAlert({ message: this.$options.i18n.deleteErrorGeneric, captureError: true, error });
}
}),
+ handleSaveEdit(eventDetails) {
+ this.editTimelineEventActive = true;
+ return this.$apollo
+ .mutate({
+ mutation: editTimelineEvent,
+ variables: {
+ input: {
+ id: eventDetails.id,
+ note: eventDetails.note,
+ occurredAt: eventDetails.occurredAt,
+ },
+ },
+ })
+ .then(({ data }) => {
+ this.editTimelineEventActive = false;
+ const errors = data.timelineEventUpdate?.errors;
+ if (errors.length) {
+ createAlert({
+ message: sprintf(this.$options.i18n.editError, { error: errors.join('. ') }, false),
+ });
+ } else {
+ this.hideEdit();
+ }
+ })
+ .catch((error) => {
+ createAlert({
+ message: this.$options.i18n.editErrorGeneric,
+ captureError: true,
+ error,
+ });
+ });
+ },
},
};
</script>
@@ -92,9 +131,10 @@ export default {
<template>
<div class="issuable-discussion incident-timeline-events">
<div
- v-for="([eventDate, events], groupIndex) in dateGroupedEvents"
+ v-for="[eventDate, events] in dateGroupedEvents"
:key="eventDate"
data-testid="timeline-group"
+ class="timeline-group"
>
<div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid">
<strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong>
@@ -103,15 +143,25 @@ export default {
<li
v-for="(event, eventIndex) in events"
:key="eventIndex"
- class="timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!"
+ class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-2! gl-pr-0!"
>
+ <edit-timeline-event
+ v-if="eventToEdit === event.id"
+ :key="`edit-${event.id}`"
+ ref="eventForm"
+ :event="event"
+ :edit-timeline-event-active="editTimelineEventActive"
+ @handle-save-edit="handleSaveEdit"
+ @hide-edit="hideEdit()"
+ />
<incident-timeline-event-item
+ v-else
:key="event.id"
:action="event.action"
:occurred-at="event.occurredAt"
:note-html="event.noteHtml"
- :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)"
@delete="handleDelete(event)"
+ @edit="handleEditSelection(event)"
/>
</li>
</ul>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
index 7c2a7878c58..5f70d9acac9 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
@@ -3,10 +3,10 @@ import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/graphql_shared/constants';
import { fetchPolicies } from '~/lib/graphql';
+import notesEventHub from '~/notes/event_hub';
import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
import { displayAndLogError } from './utils';
import { timelineTabI18n } from './constants';
-
import CreateTimelineEvent from './create_timeline_event.vue';
import IncidentTimelineEventsList from './timeline_events_list.vue';
@@ -20,7 +20,7 @@ export default {
IncidentTimelineEventsList,
},
i18n: timelineTabI18n,
- inject: ['canUpdate', 'fullPath', 'issuableId'],
+ inject: ['canUpdateTimelineEvent', 'fullPath', 'issuableId'],
data() {
return {
isEventFormVisible: false,
@@ -56,15 +56,21 @@ export default {
return !this.timelineEventLoading && !this.hasTimelineEvents;
},
},
+ mounted() {
+ notesEventHub.$on('comment-promoted-to-timeline-event', this.refreshTimelineEvents);
+ },
+ destroyed() {
+ notesEventHub.$off('comment-promoted-to-timeline-event', this.refreshTimelineEvents);
+ },
methods: {
+ refreshTimelineEvents() {
+ this.$apollo.queries.timelineEvents.refetch();
+ },
hideEventForm() {
this.isEventFormVisible = false;
},
- async showEventForm() {
- this.$refs.createEventForm.clearForm();
+ showEventForm() {
this.isEventFormVisible = true;
- await this.$nextTick();
- this.$refs.createEventForm.focusDate();
},
},
};
@@ -85,14 +91,19 @@ export default {
@hide-new-timeline-events-form="hideEventForm"
/>
<create-timeline-event
- v-show="isEventFormVisible"
+ v-if="isEventFormVisible"
ref="createEventForm"
:has-timeline-events="hasTimelineEvents"
class="timeline-event-note timeline-event-note-form"
:class="{ 'gl-pl-0': !hasTimelineEvents }"
@hide-new-timeline-events-form="hideEventForm"
/>
- <gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm">
+ <gl-button
+ v-if="canUpdateTimelineEvent"
+ variant="default"
+ class="gl-mb-3 gl-mt-7"
+ @click="showEventForm"
+ >
{{ $options.i18n.addEventButton }}
</gl-button>
</gl-tab>
diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js
index cf790a11b67..5a009debd75 100644
--- a/app/assets/javascripts/issues/show/components/incidents/utils.js
+++ b/app/assets/javascripts/issues/show/components/incidents/utils.js
@@ -21,13 +21,14 @@ export const getEventIcon = (actionName) => {
};
/**
- * Returns a date shifted by the current timezone offset. Allows
- * date.getHours() and similar to return UTC values.
- *
+ * Returns a date shifted by the current timezone offset set to now
+ * by default but can accept an existing date as an ISO date string
+ * @param {string} ISOString
* @returns {Date}
*/
-export const getUtcShiftedDateNow = () => {
- const date = new Date();
+export const getUtcShiftedDate = (ISOString = null) => {
+ const date = ISOString ? new Date(ISOString) : new Date();
date.setMinutes(date.getMinutes() + date.getTimezoneOffset());
+
return date;
};
diff --git a/app/assets/javascripts/issues/show/graphql.js b/app/assets/javascripts/issues/show/graphql.js
index 5b8630f7d63..deee034f9d1 100644
--- a/app/assets/javascripts/issues/show/graphql.js
+++ b/app/assets/javascripts/issues/show/graphql.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { defaultClient } from '~/sidebar/graphql';
+import { defaultClient } from '~/graphql_shared/issuable_client';
Vue.use(VueApollo);
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 459a3804837..e5eed9f6b79 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -32,6 +32,7 @@ export function initIncidentApp(issueData = {}) {
const {
canCreateIncident,
canUpdate,
+ canUpdateTimelineEvent,
iid,
issuableId,
projectNamespace,
@@ -51,6 +52,7 @@ export function initIncidentApp(issueData = {}) {
provide: {
issueType: INCIDENT_TYPE,
canCreateIncident,
+ canUpdateTimelineEvent,
canUpdate,
fullPath,
iid,
diff --git a/app/assets/javascripts/issues/show/utils/update_description.js b/app/assets/javascripts/issues/show/utils/update_description.js
index c5811290e61..aeb547b9194 100644
--- a/app/assets/javascripts/issues/show/utils/update_description.js
+++ b/app/assets/javascripts/issues/show/utils/update_description.js
@@ -13,6 +13,7 @@ const updateDescription = (descriptionHtml = '', details) => {
}
const placeholder = document.createElement('div');
+ // eslint-disable-next-line no-unsanitized/property
placeholder.innerHTML = descriptionHtml;
const newDetails = placeholder.getElementsByTagName('details');
diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js
index de67703356f..c79d7002111 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/api.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/api.js
@@ -1,10 +1,25 @@
import axios from 'axios';
+import { buildApiUrl } from '~/api/api_utils';
+
+import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants';
import { getJwt } from './utils';
+const CURRENT_USER_PATH = '/api/:version/user';
+const JIRA_CONNECT_SUBSCRIPTIONS_PATH = '/api/:version/integrations/jira_connect/subscriptions';
+const JIRA_CONNECT_INSTALLATIONS_PATH = '/-/jira_connect/installations';
+const JIRA_CONNECT_OAUTH_APPLICATION_ID_PATH = '/-/jira_connect/oauth_application_id';
+
+// This export is only used for testing purposes
+export const axiosInstance = axios.create();
+
+export const setApiBaseURL = (baseURL = null) => {
+ axiosInstance.defaults.baseURL = baseURL;
+};
+
export const addSubscription = async (addPath, namespace) => {
const jwt = await getJwt();
- return axios.post(addPath, {
+ return axiosInstance.post(addPath, {
jwt,
namespace_path: namespace,
});
@@ -13,7 +28,7 @@ export const addSubscription = async (addPath, namespace) => {
export const removeSubscription = async (removePath) => {
const jwt = await getJwt();
- return axios.delete(removePath, {
+ return axiosInstance.delete(removePath, {
params: {
jwt,
},
@@ -21,7 +36,7 @@ export const removeSubscription = async (removePath) => {
};
export const fetchGroups = async (groupsPath, { page, perPage, search }) => {
- return axios.get(groupsPath, {
+ return axiosInstance.get(groupsPath, {
params: {
page,
per_page: perPage,
@@ -33,9 +48,50 @@ export const fetchGroups = async (groupsPath, { page, perPage, search }) => {
export const fetchSubscriptions = async (subscriptionsPath) => {
const jwt = await getJwt();
- return axios.get(subscriptionsPath, {
+ return axiosInstance.get(subscriptionsPath, {
params: {
jwt,
},
});
};
+
+export const getCurrentUser = (options) => {
+ const url = buildApiUrl(CURRENT_USER_PATH);
+ return axiosInstance.get(url, { ...options });
+};
+
+export const addJiraConnectSubscription = (namespacePath, { jwt, accessToken }) => {
+ const url = buildApiUrl(JIRA_CONNECT_SUBSCRIPTIONS_PATH);
+
+ return axiosInstance.post(
+ url,
+ {
+ jwt,
+ namespace_path: namespacePath,
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+};
+
+export const updateInstallation = async (instanceUrl) => {
+ const jwt = await getJwt();
+
+ return axiosInstance.put(JIRA_CONNECT_INSTALLATIONS_PATH, {
+ jwt,
+ installation: {
+ instance_url: instanceUrl === GITLAB_COM_BASE_PATH ? null : instanceUrl,
+ },
+ });
+};
+
+export const fetchOAuthApplicationId = () => {
+ return axiosInstance.get(JIRA_CONNECT_OAUTH_APPLICATION_ID_PATH);
+};
+
+export const fetchOAuthToken = (oauthTokenPath, data = {}) => {
+ return axiosInstance.post(oauthTokenPath, data);
+};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 66aea60c5b5..22a6c0751f4 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -83,7 +83,7 @@ export default {
* if the jiraConnectOauth flag is enabled.
*/
fetchSubscriptionsOauth() {
- if (!this.isOauthEnabled) return;
+ if (!this.isOauthEnabled || !this.userSignedIn) return;
this.fetchSubscriptions(this.subscriptionsPath);
},
@@ -146,12 +146,12 @@ export default {
<div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
<sign-in-page
- v-if="!userSignedIn"
+ v-show="!userSignedIn"
:has-subscriptions="hasSubscriptions"
@sign-in-oauth="onSignInOauth"
@error="onSignInError"
/>
- <subscriptions-page v-else :has-subscriptions="hasSubscriptions" />
+ <subscriptions-page v-if="userSignedIn" :has-subscriptions="hasSubscriptions" />
</div>
</div>
</main>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
index c5b56535247..9b50681515e 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
@@ -3,6 +3,7 @@ import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const COMPATIBILITY_ALERT_STATE_KEY = 'compatibility_alert_dismissed';
@@ -14,6 +15,7 @@ export default {
GlLink,
LocalStorageSync,
},
+ mixins: [glFeatureFlagMixin()],
data() {
return {
alertDismissed: false,
@@ -23,6 +25,14 @@ export default {
shouldShowAlert() {
return !this.alertDismissed;
},
+ isOauthSelfManagedEnabled() {
+ return this.glFeatures.jiraConnectOauth && this.glFeatures.jiraConnectOauthSelfManaged;
+ },
+ alertBody() {
+ return this.isOauthSelfManagedEnabled
+ ? this.$options.i18n.body
+ : this.$options.i18n.bodyDotCom;
+ },
},
methods: {
dismissAlert() {
@@ -32,6 +42,9 @@ export default {
i18n: {
title: s__('Integrations|Known limitations'),
body: s__(
+ 'Integrations|Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.',
+ ),
+ bodyDotCom: s__(
'Integrations|This integration only works with GitLab.com. Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.',
),
},
@@ -50,7 +63,7 @@ export default {
:title="$options.i18n.title"
@dismiss="dismissAlert"
>
- <gl-sprintf :message="$options.i18n.body">
+ <gl-sprintf :message="alertBody">
<template #link="{ content }">
<gl-link :href="$options.DOCS_LINK_URL" target="_blank">{{ content }}</gl-link>
</template>
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
index ad3e70bcb5f..4cf3a1a0279 100644
--- 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
@@ -1,30 +1,53 @@
<script>
import { mapActions, mapMutations } from 'vuex';
import { GlButton } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
+import { sprintf } from '~/locale';
+
import {
I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
+ I18N_CUSTOM_SIGN_IN_BUTTON_TEXT,
+ I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE,
+ I18N_OAUTH_FAILED_TITLE,
+ I18N_OAUTH_FAILED_MESSAGE,
+ OAUTH_SELF_MANAGED_DOC_LINK,
OAUTH_WINDOW_OPTIONS,
PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM,
} from '~/jira_connect/subscriptions/constants';
+import { fetchOAuthApplicationId, fetchOAuthToken } from '~/jira_connect/subscriptions/api';
import { setUrlParams } from '~/lib/utils/url_utility';
import AccessorUtilities from '~/lib/utils/accessor';
import { createCodeVerifier, createCodeChallenge } from '../pkce';
-import { SET_ACCESS_TOKEN } from '../store/mutation_types';
+import { SET_ACCESS_TOKEN, SET_ALERT } from '../store/mutation_types';
export default {
components: {
GlButton,
},
inject: ['oauthMetadata'],
+ props: {
+ gitlabBasePath: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
data() {
return {
- token: null,
loading: false,
codeVerifier: null,
+ clientId: null,
canUseCrypto: AccessorUtilities.canUseCrypto(),
};
},
+ computed: {
+ buttonText() {
+ if (!this.gitlabBasePath) {
+ return I18N_DEFAULT_SIGN_IN_BUTTON_TEXT;
+ }
+
+ return sprintf(I18N_CUSTOM_SIGN_IN_BUTTON_TEXT, { url: this.gitlabBasePath });
+ },
+ },
created() {
window.addEventListener('message', this.handleWindowMessage);
},
@@ -35,30 +58,72 @@ export default {
...mapActions(['loadCurrentUser']),
...mapMutations({
setAccessToken: SET_ACCESS_TOKEN,
+ setAlert: SET_ALERT,
}),
- async startOAuthFlow() {
- this.loading = true;
-
+ async fetchOauthClientId() {
+ const {
+ data: { application_id: clientId },
+ } = await fetchOAuthApplicationId();
+ return clientId;
+ },
+ async getOauthAuthorizeURL() {
// Generate state necessary for PKCE OAuth flow
this.codeVerifier = createCodeVerifier();
const codeChallenge = await createCodeChallenge(this.codeVerifier);
+ try {
+ this.clientId = this.gitlabBasePath
+ ? await this.fetchOauthClientId()
+ : this.oauthMetadata?.oauth_token_payload?.client_id;
+ } catch {
+ throw new Error(I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE);
+ }
// 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,
+ const oauthAuthorizeURLWithChallenge = new URL(
+ setUrlParams(
+ {
+ code_challenge: codeChallenge,
+ code_challenge_method: PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.short,
+ client_id: this.clientId,
+ },
+ oauthAuthorizeURL,
+ ),
);
- window.open(
- oauthAuthorizeURLWithChallenge,
- this.$options.i18n.defaultButtonText,
- OAUTH_WINDOW_OPTIONS,
- );
+ // Rebase URL on the specified GitLab base path (if specified).
+ if (this.gitlabBasePath) {
+ const gitlabBasePathURL = new URL(this.gitlabBasePath);
+ oauthAuthorizeURLWithChallenge.hostname = gitlabBasePathURL.hostname;
+ oauthAuthorizeURLWithChallenge.pathname = `${
+ gitlabBasePathURL.pathname === '/' ? '' : gitlabBasePathURL.pathname
+ }${oauthAuthorizeURLWithChallenge.pathname}`;
+ }
+
+ return oauthAuthorizeURLWithChallenge.toString();
+ },
+ async startOAuthFlow() {
+ try {
+ this.loading = true;
+ const oauthAuthorizeURL = await this.getOauthAuthorizeURL();
+
+ window.open(oauthAuthorizeURL, I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, OAUTH_WINDOW_OPTIONS);
+ } catch (e) {
+ if (e.message) {
+ this.setAlert({
+ message: e.message,
+ variant: 'danger',
+ });
+ } else {
+ this.setAlert({
+ linkUrl: OAUTH_SELF_MANAGED_DOC_LINK,
+ title: I18N_OAUTH_FAILED_TITLE,
+ message: this.gitlabBasePath ? I18N_OAUTH_FAILED_MESSAGE : '',
+ variant: 'danger',
+ });
+ }
+ this.loading = false;
+ }
},
async handleWindowMessage(event) {
if (window.origin !== event.origin) {
@@ -94,20 +159,18 @@ export default {
async getOAuthToken(code) {
const {
oauth_token_payload: oauthTokenPayload,
- oauth_token_url: oauthTokenURL,
+ oauth_token_path: oauthTokenPath,
} = this.oauthMetadata;
- const { data } = await axios.post(oauthTokenURL, {
+ const { data } = await fetchOAuthToken(oauthTokenPath, {
...oauthTokenPayload,
code,
code_verifier: this.codeVerifier,
+ client_id: this.clientId,
});
return data.access_token;
},
},
- i18n: {
- defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
- },
};
</script>
<template>
@@ -119,7 +182,7 @@ export default {
@click="startOAuthFlow"
>
<slot>
- {{ $options.i18n.defaultButtonText }}
+ {{ buttonText }}
</slot>
</gl-button>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js
index 8faafb1b0d0..fc365746b54 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/constants.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js
@@ -3,11 +3,13 @@ import { helpPagePath } from '~/helpers/help_page_helper';
export const DEFAULT_GROUPS_PER_PAGE = 10;
export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert';
+export const BASE_URL_LOCALSTORAGE_KEY = 'gitlab_base_url';
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_CUSTOM_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to %{url}');
export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.');
export const I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE = s__(
'Integrations|Failed to load subscriptions.',
@@ -18,13 +20,28 @@ export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE = s__(
export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE = s__(
'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}',
);
-export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira_development_panel', {
- anchor: 'use-the-integration',
-});
-
export const I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE = s__(
'Integrations|Failed to link namespace. Please try again.',
);
+export const I18N_UPDATE_INSTALLATION_ERROR_MESSAGE = s__(
+ 'Integrations|Failed to update GitLab version. Please try again.',
+);
+export const I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE = s__(
+ 'Integrations|Failed to load Jira Connect Application ID. Please try again.',
+);
+export const I18N_OAUTH_FAILED_TITLE = s__('Integrations|Failed to sign in to GitLab.');
+export const I18N_OAUTH_FAILED_MESSAGE = s__(
+ 'Integrations|Ensure your instance URL is correct and your instance is configured correctly. %{linkStart}Learn more%{linkEnd}.',
+);
+
+export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_panel', {
+ anchor: 'use-the-integration',
+});
+export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', {
+ anchor: 'install-the-gitlabcom-for-jira-cloud-app-for-self-managed-instances',
+});
+
+export const GITLAB_COM_BASE_PATH = 'https://gitlab.com';
const OAUTH_WINDOW_SIZE = 800;
export const OAUTH_WINDOW_OPTIONS = [
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
index 4f5aa4c255c..5ff75e19425 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
@@ -1,6 +1,13 @@
<script>
+import { mapMutations } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
+
+import { reloadPage, persistBaseUrl, retrieveBaseUrl } from '~/jira_connect/subscriptions/utils';
+import { updateInstallation, setApiBaseURL } from '~/jira_connect/subscriptions/api';
+import { I18N_UPDATE_INSTALLATION_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants';
+import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
+
import SignInOauthButton from '../../../components/sign_in_oauth_button.vue';
import VersionSelectForm from './version_select_form.vue';
@@ -14,6 +21,7 @@ export default {
data() {
return {
gitlabBasePath: null,
+ loadingVersionSelect: false,
};
},
computed: {
@@ -26,12 +34,32 @@ export default {
: this.$options.i18n.versionSelectSubtitle;
},
},
+ mounted() {
+ this.gitlabBasePath = retrieveBaseUrl();
+ setApiBaseURL(this.gitlabBasePath);
+ },
methods: {
+ ...mapMutations({
+ setAlert: SET_ALERT,
+ }),
resetGitlabBasePath() {
this.gitlabBasePath = null;
+ setApiBaseURL();
},
onVersionSelect(gitlabBasePath) {
- this.gitlabBasePath = gitlabBasePath;
+ this.loadingVersionSelect = true;
+ updateInstallation(gitlabBasePath)
+ .then(() => {
+ persistBaseUrl(gitlabBasePath);
+ reloadPage();
+ })
+ .catch(() => {
+ this.setAlert({
+ message: I18N_UPDATE_INSTALLATION_ERROR_MESSAGE,
+ variant: 'danger',
+ });
+ this.loadingVersionSelect = false;
+ });
},
onSignInError() {
this.$emit('error');
@@ -53,11 +81,17 @@ export default {
<p data-testid="subtitle">{{ subtitle }}</p>
</div>
- <version-select-form v-if="!hasSelectedVersion" class="gl-mt-7" @submit="onVersionSelect" />
+ <version-select-form
+ v-if="!hasSelectedVersion"
+ class="gl-mt-7"
+ :loading="loadingVersionSelect"
+ @submit="onVersionSelect"
+ />
<div v-else class="gl-text-center">
<sign-in-oauth-button
class="gl-mb-5"
+ :gitlab-base-path="gitlabBasePath"
@sign-in="$emit('sign-in-oauth', $event)"
@error="onSignInError"
/>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
index 0fa745ed7e3..6b32225ed11 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
@@ -9,13 +9,14 @@ import {
} from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants';
+
const RADIO_OPTIONS = {
saas: 'saas',
selfManaged: 'selfManaged',
};
const DEFAULT_RADIO_OPTION = RADIO_OPTIONS.saas;
-const GITLAB_COM_BASE_PATH = 'https://gitlab.com';
export default {
name: 'VersionSelectForm',
@@ -27,6 +28,13 @@ export default {
GlFormRadio,
GlButton,
},
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
data() {
return {
selected: DEFAULT_RADIO_OPTION,
@@ -82,7 +90,7 @@ export default {
</gl-form-group>
<div class="gl-display-flex gl-justify-content-end">
- <gl-button variant="confirm" type="submit">{{ __('Save') }}</gl-button>
+ <gl-button variant="confirm" type="submit" :loading="loading">{{ __('Save') }}</gl-button>
</div>
</gl-form>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
index 4a83ee8671d..fff34e1d75d 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
@@ -1,6 +1,8 @@
-import { fetchSubscriptions as fetchSubscriptionsREST } from '~/jira_connect/subscriptions/api';
-import { getCurrentUser } from '~/rest_api';
-import { addJiraConnectSubscription } from '~/api/integrations_api';
+import {
+ fetchSubscriptions as fetchSubscriptionsREST,
+ getCurrentUser,
+ addJiraConnectSubscription,
+} from '~/jira_connect/subscriptions/api';
import {
I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/state.js b/app/assets/javascripts/jira_connect/subscriptions/store/state.js
index 03a83f18b4c..82a8517b511 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/state.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/state.js
@@ -1,4 +1,8 @@
-export default function createState({ subscriptions = [], subscriptionsLoading = false } = {}) {
+export default function createState({
+ subscriptions = [],
+ subscriptionsLoading = false,
+ currentUser = null,
+} = {}) {
return {
alert: undefined,
@@ -9,7 +13,7 @@ export default function createState({ subscriptions = [], subscriptionsLoading =
addSubscriptionLoading: false,
addSubscriptionError: false,
- currentUser: null,
+ currentUser,
currentUserError: null,
accessToken: null,
diff --git a/app/assets/javascripts/jira_connect/subscriptions/utils.js b/app/assets/javascripts/jira_connect/subscriptions/utils.js
index b2d03a1fbba..6db8b62d692 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/utils.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/utils.js
@@ -1,32 +1,45 @@
import AccessorUtilities from '~/lib/utils/accessor';
import { objectToQuery } from '~/lib/utils/url_utility';
-import { ALERT_LOCALSTORAGE_KEY } from './constants';
+import { ALERT_LOCALSTORAGE_KEY, BASE_URL_LOCALSTORAGE_KEY } from './constants';
const isFunction = (fn) => typeof fn === 'function';
+const { canUseLocalStorage } = AccessorUtilities;
+
+const persistToStorage = (key, payload) => {
+ localStorage.setItem(key, payload);
+};
+
+const retrieveFromStorage = (key) => {
+ return localStorage.getItem(key);
+};
+
+const removeFromStorage = (key) => {
+ localStorage.removeItem(key);
+};
/**
* Persist alert data to localStorage.
*/
export const persistAlert = ({ title, message, linkUrl, variant } = {}) => {
- if (!AccessorUtilities.canUseLocalStorage()) {
+ if (!canUseLocalStorage()) {
return;
}
const payload = JSON.stringify({ title, message, linkUrl, variant });
- localStorage.setItem(ALERT_LOCALSTORAGE_KEY, payload);
+ persistToStorage(ALERT_LOCALSTORAGE_KEY, payload);
};
/**
* Return alert data from localStorage.
*/
export const retrieveAlert = () => {
- if (!AccessorUtilities.canUseLocalStorage()) {
+ if (!canUseLocalStorage()) {
return null;
}
- const initialAlertJSON = localStorage.getItem(ALERT_LOCALSTORAGE_KEY);
+ const initialAlertJSON = retrieveFromStorage(ALERT_LOCALSTORAGE_KEY);
// immediately clean up
- localStorage.removeItem(ALERT_LOCALSTORAGE_KEY);
+ removeFromStorage(ALERT_LOCALSTORAGE_KEY);
if (!initialAlertJSON) {
return null;
@@ -35,6 +48,22 @@ export const retrieveAlert = () => {
return JSON.parse(initialAlertJSON);
};
+export const persistBaseUrl = (baseUrl) => {
+ if (!canUseLocalStorage()) {
+ return;
+ }
+
+ persistToStorage(BASE_URL_LOCALSTORAGE_KEY, baseUrl);
+};
+
+export const retrieveBaseUrl = () => {
+ if (!canUseLocalStorage()) {
+ return null;
+ }
+
+ return retrieveFromStorage(BASE_URL_LOCALSTORAGE_KEY);
+};
+
export const getJwt = () => {
return new Promise((resolve) => {
if (isFunction(AP?.context?.getToken)) {
diff --git a/app/assets/javascripts/jobs/components/filtered_search/constants.js b/app/assets/javascripts/jobs/components/filtered_search/constants.js
new file mode 100644
index 00000000000..0daba892375
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/filtered_search/constants.js
@@ -0,0 +1,13 @@
+export const jobStatusValues = [
+ 'CANCELED',
+ 'CREATED',
+ 'FAILED',
+ 'MANUAL',
+ 'SUCCESS',
+ 'PENDING',
+ 'PREPARING',
+ 'RUNNING',
+ 'SCHEDULED',
+ 'SKIPPED',
+ 'WAITING_FOR_RESOURCE',
+];
diff --git a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue
index fe7b7428c6e..e498a735898 100644
--- a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue
+++ b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue
@@ -11,6 +11,13 @@ export default {
components: {
GlFilteredSearch,
},
+ props: {
+ queryString: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
computed: {
tokens() {
return [
@@ -24,6 +31,20 @@ export default {
},
];
},
+ filteredSearchValue() {
+ if (this.queryString?.statuses) {
+ return [
+ {
+ type: 'status',
+ value: {
+ data: this.queryString?.statuses,
+ operator: '=',
+ },
+ },
+ ];
+ }
+ return [];
+ },
},
methods: {
onSubmit(filters) {
@@ -37,6 +58,7 @@ export default {
<gl-filtered-search
:placeholder="s__('Jobs|Filter jobs')"
:available-tokens="tokens"
+ :value="filteredSearchValue"
@submit="onSubmit"
/>
</template>
diff --git a/app/assets/javascripts/jobs/components/filtered_search/utils.js b/app/assets/javascripts/jobs/components/filtered_search/utils.js
new file mode 100644
index 00000000000..696cd8d4706
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/filtered_search/utils.js
@@ -0,0 +1,27 @@
+import { jobStatusValues } from './constants';
+
+// validates query string used for filtered search
+// on jobs table to ensure GraphQL query is called correctly
+export const validateQueryString = (queryStringObj) => {
+ // currently only one token is supported `statuses`
+ // this code will need to be expanded as more tokens
+ // are introduced
+
+ const filters = Object.keys(queryStringObj);
+
+ if (filters.includes('statuses')) {
+ const queryStringStatus = {
+ statuses: queryStringObj.statuses.toUpperCase(),
+ };
+
+ const found = jobStatusValues.find((status) => status === queryStringStatus.statuses);
+
+ if (found) {
+ return queryStringStatus;
+ }
+
+ return null;
+ }
+
+ return null;
+};
diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/job/empty_state.vue
index e31c13f40b0..65b9600e664 100644
--- a/app/assets/javascripts/jobs/components/empty_state.vue
+++ b/app/assets/javascripts/jobs/components/job/empty_state.vue
@@ -1,12 +1,16 @@
<script>
import { GlLink } from '@gitlab/ui';
-import ManualVariablesForm from './manual_variables_form.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import LegacyManualVariablesForm from '~/jobs/components/job/legacy_manual_variables_form.vue';
+import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue';
export default {
components: {
GlLink,
+ LegacyManualVariablesForm,
ManualVariablesForm,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
illustrationPath: {
type: String,
@@ -50,6 +54,9 @@ export default {
},
},
computed: {
+ isGraphQL() {
+ return this.glFeatures?.graphqlJobApp;
+ },
shouldRenderManualVariables() {
return this.playable && !this.scheduled;
},
@@ -70,7 +77,12 @@ export default {
<p v-if="content" data-testid="job-empty-state-content">{{ content }}</p>
</div>
- <manual-variables-form v-if="shouldRenderManualVariables" :action="action" />
+ <template v-if="isGraphQL">
+ <manual-variables-form v-if="shouldRenderManualVariables" :action="action" />
+ </template>
+ <template v-else>
+ <legacy-manual-variables-form v-if="shouldRenderManualVariables" :action="action" />
+ </template>
<div class="text-content">
<div v-if="action && !shouldRenderManualVariables" class="text-center">
<gl-link
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/job/environments_block.vue
index 4046e1ade82..4046e1ade82 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/job/environments_block.vue
diff --git a/app/assets/javascripts/jobs/components/erased_block.vue b/app/assets/javascripts/jobs/components/job/erased_block.vue
index a815689659e..a815689659e 100644
--- a/app/assets/javascripts/jobs/components/erased_block.vue
+++ b/app/assets/javascripts/jobs/components/job/erased_block.vue
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue
index d5ee3423d70..81b65d175a7 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job/job_app.vue
@@ -6,15 +6,15 @@ import { mapGetters, mapState, mapActions } from 'vuex';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
import { __, sprintf } from '~/locale';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
-import delayedJobMixin from '../mixins/delayed_job_mixin';
+import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+import Log from '~/jobs/components/log/log.vue';
import EmptyState from './empty_state.vue';
import EnvironmentsBlock from './environments_block.vue';
import ErasedBlock from './erased_block.vue';
import LogTopBar from './job_log_controllers.vue';
-import Log from './log/log.vue';
-import Sidebar from './sidebar.vue';
import StuckBlock from './stuck_block.vue';
import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue';
+import Sidebar from './sidebar/sidebar.vue';
export default {
name: 'JobPageApp',
@@ -197,7 +197,7 @@ export default {
</script>
<template>
<div>
- <gl-loading-icon v-if="isLoading" size="lg" class="qa-loading-animation gl-mt-6" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-6" />
<template v-else-if="shouldRenderContent">
<div class="build-page" data-testid="job-content">
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
index e9809ac661b..e9809ac661b 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue
index 07ef4f054b4..1898e02c94e 100644
--- a/app/assets/javascripts/jobs/components/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue
@@ -77,9 +77,6 @@ export default {
},
methods: {
...mapActions(['triggerManualJob']),
- canRemove(index) {
- return index < this.variables.length - 1;
- },
addEmptyVariable() {
const lastVar = this.variables[this.variables.length - 1];
@@ -93,12 +90,18 @@ export default {
id: uniqueId(),
});
},
+ canRemove(index) {
+ return index < this.variables.length - 1;
+ },
deleteVariable(id) {
this.variables.splice(
this.variables.findIndex((el) => el.id === id),
1,
);
},
+ inputRef(type, id) {
+ return `${this.$options.inputTypes[type]}-${id}`;
+ },
trigger() {
this.triggerBtnDisabled = true;
@@ -125,7 +128,7 @@ export default {
</gl-input-group-text>
</template>
<gl-form-input
- :ref="`${$options.inputTypes.key}-${variable.id}`"
+ :ref="inputRef('key', variable.id)"
v-model="variable.key"
:placeholder="$options.i18n.keyPlaceholder"
data-testid="ci-variable-key"
@@ -140,20 +143,13 @@ export default {
</gl-input-group-text>
</template>
<gl-form-input
- :ref="`${$options.inputTypes.value}-${variable.id}`"
+ :ref="inputRef('value', variable.id)"
v-model="variable.secretValue"
:placeholder="$options.i18n.valuePlaceholder"
data-testid="ci-variable-value"
/>
</gl-form-input-group>
- <!-- delete variable button placeholder to not break flex layout -->
- <div
- v-if="!canRemove(index)"
- class="gl-w-7 gl-mr-3"
- data-testid="delete-variable-btn-placeholder"
- ></div>
-
<gl-button
v-if="canRemove(index)"
class="gl-flex-grow-0 gl-flex-basis-0"
@@ -164,6 +160,9 @@ export default {
data-testid="delete-variable-btn"
@click="deleteVariable(variable.id)"
/>
+
+ <!-- delete variable button placeholder to not break flex layout -->
+ <div v-else class="gl-w-7 gl-mr-3" data-testid="delete-variable-btn-placeholder"></div>
</div>
<div class="gl-text-center gl-mt-5">
diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
new file mode 100644
index 00000000000..2f97301979c
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
@@ -0,0 +1,195 @@
+<script>
+import {
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlFormInput,
+ GlButton,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { mapActions } from 'vuex';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+// This component is a port of ~/jobs/components/job/legacy_manual_variables_form.vue
+// It is meant to fetch the job information via GraphQL instead of REST API.
+
+export default {
+ name: 'ManualVariablesForm',
+ components: {
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlFormInput,
+ GlButton,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ action: {
+ type: Object,
+ required: false,
+ default: null,
+ validator(value) {
+ return (
+ value === null ||
+ (Object.prototype.hasOwnProperty.call(value, 'path') &&
+ Object.prototype.hasOwnProperty.call(value, 'method') &&
+ Object.prototype.hasOwnProperty.call(value, 'button_title'))
+ );
+ },
+ },
+ },
+ inputTypes: {
+ key: 'key',
+ value: 'value',
+ },
+ i18n: {
+ header: s__('CiVariables|Variables'),
+ keyLabel: s__('CiVariables|Key'),
+ valueLabel: s__('CiVariables|Value'),
+ keyPlaceholder: s__('CiVariables|Input variable key'),
+ valuePlaceholder: s__('CiVariables|Input variable value'),
+ formHelpText: s__(
+ 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default',
+ ),
+ },
+ data() {
+ return {
+ variables: [
+ {
+ key: '',
+ secretValue: '',
+ id: uniqueId(),
+ },
+ ],
+ triggerBtnDisabled: false,
+ };
+ },
+ computed: {
+ variableSettings() {
+ return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' });
+ },
+ preparedVariables() {
+ // we need to ensure no empty variables are passed to the API
+ // and secretValue should be snake_case when passed to the API
+ return this.variables
+ .filter((variable) => variable.key !== '')
+ .map(({ key, secretValue }) => ({ key, secret_value: secretValue }));
+ },
+ },
+ methods: {
+ ...mapActions(['triggerManualJob']),
+ addEmptyVariable() {
+ const lastVar = this.variables[this.variables.length - 1];
+
+ if (lastVar.key === '') {
+ return;
+ }
+
+ this.variables.push({
+ key: '',
+ secret_value: '',
+ id: uniqueId(),
+ });
+ },
+ canRemove(index) {
+ return index < this.variables.length - 1;
+ },
+ deleteVariable(id) {
+ this.variables.splice(
+ this.variables.findIndex((el) => el.id === id),
+ 1,
+ );
+ },
+ inputRef(type, id) {
+ return `${this.$options.inputTypes[type]}-${id}`;
+ },
+ trigger() {
+ this.triggerBtnDisabled = true;
+
+ this.triggerManualJob(this.preparedVariables);
+ },
+ },
+};
+</script>
+<template>
+ <div class="row gl-justify-content-center">
+ <div class="col-10" data-testid="manual-vars-form">
+ <label>{{ $options.i18n.header }}</label>
+
+ <div
+ v-for="(variable, index) in variables"
+ :key="variable.id"
+ class="gl-display-flex gl-align-items-center gl-mb-4"
+ data-testid="ci-variable-row"
+ >
+ <gl-form-input-group class="gl-mr-4 gl-flex-grow-1">
+ <template #prepend>
+ <gl-input-group-text>
+ {{ $options.i18n.keyLabel }}
+ </gl-input-group-text>
+ </template>
+ <gl-form-input
+ :ref="inputRef('key', variable.id)"
+ v-model="variable.key"
+ :placeholder="$options.i18n.keyPlaceholder"
+ data-testid="ci-variable-key"
+ @change="addEmptyVariable"
+ />
+ </gl-form-input-group>
+
+ <gl-form-input-group class="gl-flex-grow-2">
+ <template #prepend>
+ <gl-input-group-text>
+ {{ $options.i18n.valueLabel }}
+ </gl-input-group-text>
+ </template>
+ <gl-form-input
+ :ref="inputRef('value', variable.id)"
+ v-model="variable.secretValue"
+ :placeholder="$options.i18n.valuePlaceholder"
+ data-testid="ci-variable-value"
+ />
+ </gl-form-input-group>
+
+ <gl-button
+ v-if="canRemove(index)"
+ class="gl-flex-grow-0 gl-flex-basis-0"
+ category="tertiary"
+ variant="danger"
+ icon="clear"
+ :aria-label="__('Delete variable')"
+ data-testid="delete-variable-btn"
+ @click="deleteVariable(variable.id)"
+ />
+
+ <!-- delete variable button placeholder to not break flex layout -->
+ <div v-else class="gl-w-7 gl-mr-3" data-testid="delete-variable-btn-placeholder"></div>
+ </div>
+
+ <div class="gl-text-center gl-mt-5">
+ <gl-sprintf :message="$options.i18n.formHelpText">
+ <template #link="{ content }">
+ <gl-link :href="variableSettings" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-button
+ class="gl-mt-5"
+ variant="confirm"
+ category="primary"
+ :aria-label="__('Trigger manual job')"
+ :disabled="triggerBtnDisabled"
+ data-testid="trigger-manual-job-btn"
+ @click="trigger"
+ >
+ {{ action.button_title }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
index 2018942a7e8..2018942a7e8 100644
--- a/app/assets/javascripts/jobs/components/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue
index 7f25ca8a94d..7f25ca8a94d 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue
index 097ab3b4cf6..097ab3b4cf6 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue
diff --git a/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
index e83ed6c6332..913924cc7b1 100644
--- a/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlLink, GlModal } from '@gitlab/ui';
-import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '../constants';
+import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants';
export default {
name: 'JobRetryForwardDeploymentModal',
diff --git a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
index a7bf365d35c..dd620977f0c 100644
--- a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
@@ -1,12 +1,12 @@
<script>
import { GlButton, GlModalDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
-import { JOB_SIDEBAR } from '../constants';
+import { JOB_SIDEBAR_COPY } from '~/jobs/constants';
export default {
name: 'JobSidebarRetryButton',
i18n: {
- retryLabel: JOB_SIDEBAR.retry,
+ retryLabel: JOB_SIDEBAR_COPY.retry,
},
components: {
GlButton,
diff --git a/app/assets/javascripts/jobs/components/jobs_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue
index df64b6422c7..df64b6422c7 100644
--- a/app/assets/javascripts/jobs/components/jobs_container.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue
new file mode 100644
index 00000000000..263b2d141c9
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue
@@ -0,0 +1,99 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants';
+import JobSidebarRetryButton from './job_sidebar_retry_button.vue';
+
+export default {
+ name: 'LegacySidebarHeader',
+ i18n: {
+ ...JOB_SIDEBAR_COPY,
+ },
+ forwardDeploymentFailureModalId,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlButton,
+ JobSidebarRetryButton,
+ TooltipOnTruncate,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ erasePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ retryButtonCategory() {
+ return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
+ },
+ },
+ methods: {
+ ...mapActions(['toggleSidebar']),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-py-5 gl-display-flex gl-align-items-center">
+ <tooltip-on-truncate :title="job.name" truncate-target="child"
+ ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
+ {{ job.name }}
+ </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"
+ variant="confirm"
+ data-qa-selector="retry_button"
+ data-testid="retry-button"
+ />
+ <gl-button
+ v-if="job.cancel_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.cancelJobButtonLabel"
+ :aria-label="$options.i18n.cancelJobButtonLabel"
+ :href="job.cancel_path"
+ variant="danger"
+ icon="cancel"
+ data-method="post"
+ data-testid="cancel-button"
+ rel="nofollow"
+ />
+ <gl-button
+ :aria-label="$options.i18n.toggleSidebar"
+ category="tertiary"
+ class="gl-md-display-none gl-ml-2"
+ icon="chevron-double-lg-right"
+ @click="toggleSidebar"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
index a42e45ee7e4..b0db48df01f 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
@@ -1,48 +1,40 @@
<script>
-import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlIcon } 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';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants';
import CommitBlock from './commit_block.vue';
-import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue';
-import JobSidebarRetryButton from './job_sidebar_retry_button.vue';
import JobsContainer from './jobs_container.vue';
+import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue';
import JobSidebarDetailsContainer from './sidebar_job_details_container.vue';
+import ArtifactsBlock from './artifacts_block.vue';
+import LegacySidebarHeader from './legacy_sidebar_header.vue';
+import SidebarHeader from './sidebar_header.vue';
import StagesDropdown from './stages_dropdown.vue';
import TriggerBlock from './trigger_block.vue';
-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,
+ ...JOB_SIDEBAR_COPY,
},
borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
forwardDeploymentFailureModalId,
- directives: {
- GlTooltip: GlTooltipDirective,
- },
components: {
ArtifactsBlock,
CommitBlock,
GlButton,
GlIcon,
JobsContainer,
- JobSidebarRetryButton,
JobRetryForwardDeploymentModal,
JobSidebarDetailsContainer,
+ LegacySidebarHeader,
+ SidebarHeader,
StagesDropdown,
- TooltipOnTruncate,
TriggerBlock,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
artifactHelpUrl: {
type: String,
@@ -58,9 +50,6 @@ export default {
computed: {
...mapGetters(['hasForwardDeploymentFailure']),
...mapState(['job', 'stages', 'jobs', 'selectedStage']),
- retryButtonCategory() {
- return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
- },
hasArtifact() {
// the artifact object will always have a locked property
return Object.keys(this.job.artifact).length > 1;
@@ -68,8 +57,8 @@ export default {
hasTriggers() {
return !isEmpty(this.job.trigger);
},
- hasStages() {
- return this.job?.pipeline?.stages?.length > 0;
+ isGraphQL() {
+ return this.glFeatures?.graphqlJobApp;
},
commit() {
return this.job?.pipeline?.commit || {};
@@ -79,7 +68,7 @@ export default {
},
},
methods: {
- ...mapActions(['fetchJobsForStage', 'toggleSidebar']),
+ ...mapActions(['fetchJobsForStage']),
},
};
</script>
@@ -87,61 +76,8 @@ export default {
<aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix">
<div class="sidebar-container">
<div class="blocks-container">
- <div class="gl-py-5 gl-display-flex gl-align-items-center">
- <tooltip-on-truncate :title="job.name" truncate-target="child"
- ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
- {{ job.name }}
- </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"
- variant="confirm"
- data-qa-selector="retry_button"
- data-testid="retry-button"
- />
- <gl-button
- v-if="job.cancel_path"
- v-gl-tooltip.left
- :title="$options.i18n.cancelJobButtonLabel"
- :aria-label="$options.i18n.cancelJobButtonLabel"
- :href="job.cancel_path"
- variant="danger"
- icon="cancel"
- data-method="post"
- data-testid="cancel-button"
- rel="nofollow"
- />
- </div>
-
- <gl-button
- :aria-label="$options.i18n.toggleSidebar"
- category="tertiary"
- class="gl-md-display-none gl-ml-2"
- icon="chevron-double-lg-right"
- @click="toggleSidebar"
- />
- </div>
-
+ <sidebar-header v-if="isGraphQL" :erase-path="erasePath" :job="job" />
+ <legacy-sidebar-header v-else :erase-path="erasePath" :job="job" />
<div
v-if="job.terminal_path || job.new_issue_path"
class="gl-py-5"
diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
index 05567328660..05567328660 100644
--- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
new file mode 100644
index 00000000000..523710598bf
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
@@ -0,0 +1,102 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants';
+import JobSidebarRetryButton from './job_sidebar_retry_button.vue';
+
+// This component is a port of ~/jobs/components/job/sidebar/legacy_sidebar_header.vue
+// It is meant to fetch the job information via GraphQL instead of REST API.
+
+export default {
+ name: 'SidebarHeader',
+ i18n: {
+ ...JOB_SIDEBAR_COPY,
+ },
+ forwardDeploymentFailureModalId,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlButton,
+ JobSidebarRetryButton,
+ TooltipOnTruncate,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ erasePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ retryButtonCategory() {
+ return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
+ },
+ },
+ methods: {
+ ...mapActions(['toggleSidebar']),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-py-5 gl-display-flex gl-align-items-center">
+ <tooltip-on-truncate :title="job.name" truncate-target="child"
+ ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
+ {{ job.name }}
+ </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"
+ variant="confirm"
+ data-qa-selector="retry_button"
+ data-testid="retry-button"
+ />
+ <gl-button
+ v-if="job.cancel_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.cancelJobButtonLabel"
+ :aria-label="$options.i18n.cancelJobButtonLabel"
+ :href="job.cancel_path"
+ variant="danger"
+ icon="cancel"
+ data-method="post"
+ data-testid="cancel-button"
+ rel="nofollow"
+ />
+ <gl-button
+ :aria-label="$options.i18n.toggleSidebar"
+ category="tertiary"
+ class="gl-md-display-none gl-ml-2"
+ icon="chevron-double-lg-right"
+ @click="toggleSidebar"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
index 3b1509e5be5..3b1509e5be5 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
index 7c4811b2d6f..e3afe9b7c67 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
@@ -4,14 +4,14 @@ 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 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: {
CiIcon,
- clipboardButton,
+ ClipboardButton,
GlDropdown,
GlDropdownItem,
GlLink,
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue
index 1afc1c9a595..1afc1c9a595 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue
diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/job/stuck_block.vue
index d7a26d22406..d7a26d22406 100644
--- a/app/assets/javascripts/jobs/components/stuck_block.vue
+++ b/app/assets/javascripts/jobs/components/job/stuck_block.vue
diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/job/unmet_prerequisites_block.vue
index c9747ca9f02..c9747ca9f02 100644
--- a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
+++ b/app/assets/javascripts/jobs/components/job/unmet_prerequisites_block.vue
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 98b51e8c2c4..851be211b25 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
@@ -11,6 +11,7 @@ query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJo
}
nodes {
artifacts {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
nodes {
downloadPath
fileType
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 c2f460cb647..0a4757d11a8 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -2,7 +2,9 @@
import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
+import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility';
import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue';
+import { validateQueryString } from '../filtered_search/utils';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue';
@@ -37,6 +39,7 @@ export default {
variables() {
return {
fullPath: this.fullPath,
+ ...this.validatedQueryString,
};
},
update(data) {
@@ -95,6 +98,11 @@ export default {
jobsCount() {
return this.jobs.count;
},
+ validatedQueryString() {
+ const queryStringObject = queryToObject(window.location.search);
+
+ return validateQueryString(queryStringObject);
+ },
},
watch: {
// this watcher ensures that the count on the all tab
@@ -133,6 +141,10 @@ export default {
}
if (filter.type === 'status') {
+ updateHistory({
+ url: setUrlParams({ statuses: filter.value.data }, window.location.href, true),
+ });
+
this.$apollo.queries.jobs.refetch({ statuses: filter.value.data });
}
});
@@ -171,12 +183,12 @@ export default {
:loading="loading"
@fetchJobsByStatus="fetchJobsByStatus"
/>
-
- <jobs-filtered-search
- v-if="showFilteredSearch"
- :class="$options.filterSearchBoxStyles"
- @filterJobsBySearch="filterJobsBySearch"
- />
+ <div v-if="showFilteredSearch" :class="$options.filterSearchBoxStyles">
+ <jobs-filtered-search
+ :query-string="validatedQueryString"
+ @filterJobsBySearch="filterJobsBySearch"
+ />
+ </div>
<div v-if="showSkeletonLoader" class="gl-mt-5">
<gl-skeleton-loader :width="1248" :height="73">
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
index 3040d4e2379..50ee7bd20dd 100644
--- a/app/assets/javascripts/jobs/constants.js
+++ b/app/assets/javascripts/jobs/constants.js
@@ -3,11 +3,17 @@ import { __, s__ } from '~/locale';
const cancel = __('Cancel');
const moreInfo = __('More information');
-export const JOB_SIDEBAR = {
+export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
+
+export const JOB_SIDEBAR_COPY = {
cancel,
+ cancelJobButtonLabel: s__('Job|Cancel'),
debug: __('Debug'),
+ eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
+ eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'),
newIssue: __('New issue'),
retry: __('Retry'),
+ retryJobButtonLabel: s__('Job|Retry'),
toggleSidebar: __('Toggle Sidebar'),
};
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 5c63ad96ad0..9dd47f4046c 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -1,6 +1,6 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
-import JobApp from './components/job_app.vue';
+import JobApp from './components/job/job_app.vue';
import createStore from './store';
Vue.use(GlToast);
diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js
index 3e5396c5bd8..51fedac339b 100644
--- a/app/assets/javascripts/labels/labels_select.js
+++ b/app/assets/javascripts/labels/labels_select.js
@@ -247,6 +247,7 @@ export default class LabelsSelect {
}
linkEl.className = selectedClass.join(' ');
+ // eslint-disable-next-line no-unsanitized/property
linkEl.innerHTML = `${colorEl} ${escape(label.title)}`;
const listItemEl = document.createElement('li');
diff --git a/app/assets/javascripts/lib/dateformat.js b/app/assets/javascripts/lib/dateformat.js
new file mode 100644
index 00000000000..1fd95dd03ab
--- /dev/null
+++ b/app/assets/javascripts/lib/dateformat.js
@@ -0,0 +1,60 @@
+import dateFormat, { i18n, masks } from 'dateformat';
+import { s__, __ } from '~/locale';
+
+i18n.dayNames = [
+ __('Sun'),
+ __('Mon'),
+ __('Tue'),
+ __('Wed'),
+ __('Thu'),
+ __('Fri'),
+ __('Sat'),
+ __('Sunday'),
+ __('Monday'),
+ __('Tuesday'),
+ __('Wednesday'),
+ __('Thursday'),
+ __('Friday'),
+ __('Saturday'),
+];
+
+i18n.monthNames = [
+ __('Jan'),
+ __('Feb'),
+ __('Mar'),
+ __('Apr'),
+ __('May'),
+ __('Jun'),
+ __('Jul'),
+ __('Aug'),
+ __('Sep'),
+ __('Oct'),
+ __('Nov'),
+ __('Dec'),
+ __('January'),
+ __('February'),
+ __('March'),
+ __('April'),
+ __('May'),
+ __('June'),
+ __('July'),
+ __('August'),
+ __('September'),
+ __('October'),
+ __('November'),
+ __('December'),
+];
+
+i18n.timeNames = [
+ s__('Time|a'),
+ s__('Time|p'),
+ s__('Time|am'),
+ s__('Time|pm'),
+ s__('Time|A'),
+ s__('Time|P'),
+ s__('Time|AM'),
+ s__('Time|PM'),
+];
+
+export { masks };
+export default dateFormat;
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
index 3e28ca2a0f7..6f24590f9e7 100644
--- a/app/assets/javascripts/lib/dompurify.js
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -1,6 +1,8 @@
-import { sanitize as dompurifySanitize, addHook } from 'dompurify';
+import DOMPurify from 'dompurify';
import { getNormalizedURL, getBaseURL, relativePathToAbsolute } from '~/lib/utils/url_utility';
+const { sanitize: dompurifySanitize, addHook, isValidAttribute } = DOMPurify;
+
const defaultConfig = {
// Safely allow SVG <use> tags
ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
@@ -94,4 +96,4 @@ addHook('afterSanitizeAttributes', (node) => {
export const sanitize = (val, config) => dompurifySanitize(val, { ...defaultConfig, ...config });
-export { isValidAttribute } from 'dompurify';
+export { isValidAttribute };
diff --git a/app/assets/javascripts/lib/gfm/constants.js b/app/assets/javascripts/lib/gfm/constants.js
new file mode 100644
index 00000000000..eaabeb2a767
--- /dev/null
+++ b/app/assets/javascripts/lib/gfm/constants.js
@@ -0,0 +1,10 @@
+export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN = '[[';
+export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN = 'TOC';
+export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN = ']]';
+export const TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN = '[TOC]';
+
+export const MDAST_TEXT_NODE = 'text';
+export const MDAST_EMPHASIS_NODE = 'emphasis';
+export const MDAST_PARAGRAPH_NODE = 'paragraph';
+
+export const GLFM_TABLE_OF_CONTENTS_NODE = 'tableOfContents';
diff --git a/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js b/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js
new file mode 100644
index 00000000000..4d2484a657a
--- /dev/null
+++ b/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js
@@ -0,0 +1,85 @@
+import { first, last } from 'lodash';
+import { u } from 'unist-builder';
+import { visitParents, SKIP, CONTINUE } from 'unist-util-visit-parents';
+import {
+ TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN,
+ TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN,
+ TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN,
+ TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN,
+ MDAST_TEXT_NODE,
+ MDAST_EMPHASIS_NODE,
+ MDAST_PARAGRAPH_NODE,
+ GLFM_TABLE_OF_CONTENTS_NODE,
+} from '../constants';
+
+const isTOCTextNode = ({ type, value }) =>
+ type === MDAST_TEXT_NODE && value === TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN;
+
+const isTOCEmphasisNode = ({ type, children }) =>
+ type === MDAST_EMPHASIS_NODE && children.length === 1 && isTOCTextNode(first(children));
+
+const isTOCDoubleSquareBracketOpenTokenTextNode = ({ type, value }) =>
+ type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN;
+
+const isTOCDoubleSquareBracketCloseTokenTextNode = ({ type, value }) =>
+ type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN;
+
+/*
+ * Detects table of contents declaration with syntax [[_TOC_]]
+ */
+const isTableOfContentsDoubleSquareBracketSyntax = ({ children }) => {
+ if (children.length !== 3) {
+ return false;
+ }
+
+ const [firstChild, middleChild, lastChild] = children;
+
+ return (
+ isTOCDoubleSquareBracketOpenTokenTextNode(firstChild) &&
+ isTOCEmphasisNode(middleChild) &&
+ isTOCDoubleSquareBracketCloseTokenTextNode(lastChild)
+ );
+};
+
+/*
+ * Detects table of contents declaration with syntax [TOC]
+ */
+const isTableOfContentsSingleSquareBracketSyntax = ({ children }) => {
+ if (children.length !== 1) {
+ return false;
+ }
+
+ const [firstChild] = children;
+ const { type, value } = firstChild;
+
+ return type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN;
+};
+
+const isTableOfContentsNode = (node) =>
+ node.type === MDAST_PARAGRAPH_NODE &&
+ (isTableOfContentsDoubleSquareBracketSyntax(node) ||
+ isTableOfContentsSingleSquareBracketSyntax(node));
+
+export default () => {
+ return (tree) => {
+ visitParents(tree, (node, ancestors) => {
+ const parent = last(ancestors);
+
+ if (!parent) {
+ return CONTINUE;
+ }
+
+ if (isTableOfContentsNode(node)) {
+ const index = parent.children.indexOf(node);
+
+ parent.children[index] = u(GLFM_TABLE_OF_CONTENTS_NODE, {
+ position: node.position,
+ });
+ }
+
+ return SKIP;
+ });
+
+ return tree;
+ };
+};
diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js
index eaf653e9924..fad73f93c1a 100644
--- a/app/assets/javascripts/lib/gfm/index.js
+++ b/app/assets/javascripts/lib/gfm/index.js
@@ -6,6 +6,8 @@ import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
import remarkRehype, { all } from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
+import glfmTableOfContents from './glfm_extensions/table_of_contents';
+import * as glfmMdastToHastHandlers from './mdast_to_hast_handlers/glfm_mdast_to_hast_handlers';
const skipFrontmatterHandler = (language) => (h, node) =>
h(node.position, 'frontmatter', { language }, [{ type: 'text', value: node.value }]);
@@ -65,19 +67,22 @@ const skipRenderingHandlers = {
all(h, node),
);
},
+ tableOfContents: (h, node) => h(node.position, 'tableOfContents'),
toml: skipFrontmatterHandler('toml'),
yaml: skipFrontmatterHandler('yaml'),
json: skipFrontmatterHandler('json'),
};
-const createParser = ({ skipRendering = [] }) => {
+const createParser = ({ skipRendering }) => {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkFrontmatter, ['yaml', 'toml', { type: 'json', marker: ';' }])
+ .use(glfmTableOfContents)
.use(remarkRehype, {
allowDangerousHtml: true,
handlers: {
+ ...glfmMdastToHastHandlers,
...pick(skipRenderingHandlers, skipRendering),
},
})
@@ -99,13 +104,13 @@ const compilerFactory = (renderer) =>
* tree in any desired representation
*
* @param {String} params.markdown Markdown to parse
- * @param {(tree: MDast -> any)} params.renderer A function that accepts mdast
+ * @param {Function} params.renderer A function that accepts mdast
* AST tree and returns an object of any type that represents the result of
* rendering the tree. See the references below to for more information
* about MDast.
*
* MDastTree documentation https://github.com/syntax-tree/mdast
- * @returns {Promise<any>} Returns a promise with the result of rendering
+ * @returns {Promise} Returns a promise with the result of rendering
* the MDast tree
*/
export const render = async ({ markdown, renderer, skipRendering = [] }) => {
diff --git a/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js b/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js
new file mode 100644
index 00000000000..91b09e69405
--- /dev/null
+++ b/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js
@@ -0,0 +1 @@
+export const tableOfContents = (h, node) => h(node.position, 'nav');
diff --git a/app/assets/javascripts/lib/mermaid.js b/app/assets/javascripts/lib/mermaid.js
index d621c9ddf9e..c72561ce69d 100644
--- a/app/assets/javascripts/lib/mermaid.js
+++ b/app/assets/javascripts/lib/mermaid.js
@@ -9,6 +9,7 @@ const setIframeRenderedSize = (h, w) => {
const drawDiagram = (source) => {
const element = document.getElementById('app');
const insertSvg = (svgCode) => {
+ // eslint-disable-next-line no-unsanitized/property
element.innerHTML = svgCode;
const height = parseInt(element.firstElementChild.getAttribute('height'), 10);
diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
index 4e7086e62c5..6c5d4ecc901 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
@@ -142,9 +142,16 @@ export const dayInQuarter = (date, quarter) => {
export const millisecondsPerDay = 1000 * 60 * 60 * 24;
-export const getDayDifference = (a, b) => {
- const date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
- const date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
+/**
+ * Calculates the number of days between 2 specified dates, excluding the current date
+ *
+ * @param {Date} startDate the earlier date that we will substract from the end date
+ * @param {Date} endDate the last date in the range
+ * @return {Number} number of days in between
+ */
+export const getDayDifference = (startDate, endDate) => {
+ const date1 = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
+ const date2 = Date.UTC(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
return Math.floor((date2 - date1) / millisecondsPerDay);
};
@@ -208,6 +215,19 @@ export const newDateAsLocaleTime = (date) => {
return new Date(`${date}${suffix}`);
};
+/**
+ * Takes a Date object (where timezone could be GMT or EST) and
+ * returns a Date object with the same date but in UTC.
+ *
+ * @param {Date} date A Date object
+ * @returns {Date|null} A Date object with the same date but in UTC
+ */
+export const getDateWithUTC = (date) => {
+ return date instanceof Date
+ ? new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
+ : null;
+};
+
export const beginOfDayTime = 'T00:00:00Z';
export const endOfDayTime = 'T23:59:59Z';
diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
index 830f4604382..d07abb72210 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -1,5 +1,5 @@
-import dateFormat from 'dateformat';
import { isString, mapValues, reduce, isDate, unescape } from 'lodash';
+import dateFormat from '~/lib/dateformat';
import { roundToNearestHalf } from '~/lib/utils/common_utils';
import { sanitize } from '~/lib/dompurify';
import { s__, n__, __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js
index 840cc4600fe..548f5a438df 100644
--- a/app/assets/javascripts/lib/utils/datetime_range.js
+++ b/app/assets/javascripts/lib/utils/datetime_range.js
@@ -1,5 +1,5 @@
-import dateformat from 'dateformat';
import { pick, omit, isEqual, isEmpty } from 'lodash';
+import dateformat from '~/lib/dateformat';
import { DATETIME_RANGE_TYPES } from './constants';
import { secondsToMilliseconds } from './datetime_utility';
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 9f4e12a3010..48be8af3ff6 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -263,10 +263,12 @@ export function insertMarkdownText({
if (tag === LINK_TAG_PATTERN) {
if (URL) {
try {
- new URL(selected); // eslint-disable-line no-new
- // valid url
- tag = '[text]({text})';
- select = 'text';
+ const url = new URL(selected);
+
+ if (url.origin !== 'null' || url.origin === null) {
+ tag = '[text]({text})';
+ select = 'text';
+ }
} catch (e) {
// ignore - no valid url
}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 7b00995b2e5..59645d50e29 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -119,6 +119,7 @@ const getAverageCharWidth = memoize(function getAverageCharWidth(options = {}) {
div.style.left = -1000;
div.style.top = -1000;
+ // eslint-disable-next-line no-unsanitized/property
div.innerHTML = chars;
document.body.appendChild(div);
diff --git a/app/assets/javascripts/linked_resources/index.js b/app/assets/javascripts/linked_resources/index.js
index 6d799d30b4b..f1d3026c2f1 100644
--- a/app/assets/javascripts/linked_resources/index.js
+++ b/app/assets/javascripts/linked_resources/index.js
@@ -22,7 +22,7 @@ export default function initLinkedResources() {
name: 'LinkedResourcesRoot',
apolloProvider,
components: {
- resourceLinksBlock: ResourceLinksBlock,
+ ResourceLinksBlock,
},
render: (createElement) =>
createElement('resource-links-block', {
diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js
index e1749331d90..c8c6b51f374 100644
--- a/app/assets/javascripts/locale/sprintf.js
+++ b/app/assets/javascripts/locale/sprintf.js
@@ -14,6 +14,8 @@ import { escape } from 'lodash';
export default (input, parameters, escapeParameters = true) => {
let output = input;
+ output = output.replace(/%+/g, '%');
+
if (parameters) {
const mappedParameters = new Map(Object.entries(parameters));
diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
index b82fb0030ff..1bb1f90302c 100644
--- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
@@ -88,7 +88,8 @@ export default {
:action-primary="actionPrimary"
:title="actionText"
:visible="removeMemberModalVisible"
- data-qa-selector="remove_member_modal_content"
+ data-qa-selector="remove_member_modal"
+ data-testid="remove-member-modal-content"
@primary="submitForm"
@hide="hideRemoveMemberModal"
>
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 93d113d1afe..3135ec602be 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -196,3 +196,8 @@ export const MEMBER_ACCESS_LEVEL_PROPERTY_NAME = 'access_level';
export const GROUP_LINK_BASE_PROPERTY_NAME = 'group_link';
export const GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME = 'group_access';
+
+export const I18N_USER_YOU = __("It's you");
+export const I18N_USER_BLOCKED = __('Blocked');
+export const I18N_USER_BOT = __('Bot');
+export const I188N_USER_2FA = __('2FA');
diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js
index 7ec083646e9..0da44b7d468 100644
--- a/app/assets/javascripts/members/utils.js
+++ b/app/assets/javascripts/members/utils.js
@@ -1,28 +1,36 @@
import { isUndefined } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getParameterByName, setUrlParams } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
import {
FIELDS,
DEFAULT_SORT,
GROUP_LINK_BASE_PROPERTY_NAME,
GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME,
+ I18N_USER_YOU,
+ I18N_USER_BLOCKED,
+ I18N_USER_BOT,
+ I188N_USER_2FA,
} from './constants';
export const generateBadges = ({ member, isCurrentUser, canManageMembers }) => [
{
show: isCurrentUser,
- text: __("It's you"),
+ text: I18N_USER_YOU,
variant: 'success',
},
{
show: member.user?.blocked,
- text: __('Blocked'),
+ text: I18N_USER_BLOCKED,
variant: 'danger',
},
{
+ show: member.user?.isBot,
+ text: I18N_USER_BOT,
+ variant: 'muted',
+ },
+ {
show: member.user?.twoFactorEnabled && (canManageMembers || isCurrentUser),
- text: __('2FA'),
+ text: I188N_USER_2FA,
variant: 'info',
},
];
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index ed2e6a5af58..0b53a8ede64 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -434,6 +434,7 @@ export default class MergeRequestTabs {
.get(`${source}.json`)
.then(({ data }) => {
const commitsDiv = document.querySelector('div#commits');
+ // eslint-disable-next-line no-unsanitized/property
commitsDiv.innerHTML = data.html;
localTimeAgo(commitsDiv.querySelectorAll('.js-timeago'));
this.commitsLoaded = true;
diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue
new file mode 100644
index 00000000000..f067982fce1
--- /dev/null
+++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue
@@ -0,0 +1,180 @@
+<script>
+import {
+ GlIntersectionObserver,
+ GlLink,
+ GlSprintf,
+ GlBadge,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
+import { mapGetters, mapState } from 'vuex';
+import { TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import StatusBox from '~/issuable/components/status_box.vue';
+import DiscussionCounter from '~/notes/components/discussion_counter.vue';
+import TodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ components: {
+ GlIntersectionObserver,
+ GlLink,
+ GlSprintf,
+ GlBadge,
+ StatusBox,
+ DiscussionCounter,
+ TodoWidget,
+ ClipboardButton,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ projectPath: { default: null },
+ title: { default: '' },
+ tabs: { default: () => [] },
+ isFluidLayout: { default: false },
+ },
+ data() {
+ return {
+ isStickyHeaderVisible: false,
+ discussionCounter: 0,
+ };
+ },
+ computed: {
+ ...mapGetters(['getNoteableData', 'discussionTabCounter']),
+ ...mapState({
+ activeTab: (state) => state.page.activeTab,
+ doneFetchingBatchDiscussions: (state) => state.notes.doneFetchingBatchDiscussions,
+ }),
+ issuableId() {
+ return convertToGraphQLId(TYPE_MERGE_REQUEST, this.getNoteableData.id);
+ },
+ issuableIid() {
+ return `${this.getNoteableData.iid}`;
+ },
+ isSignedIn() {
+ return isLoggedIn();
+ },
+ },
+ watch: {
+ discussionTabCounter(val) {
+ if (this.glFeatures.paginatedMrDiscussions) {
+ if (this.doneFetchingBatchDiscussions) {
+ this.discussionCounter = val;
+ }
+ } else {
+ this.discussionCounter = val;
+ }
+ },
+ },
+ methods: {
+ setStickyHeaderVisible(val) {
+ this.isStickyHeaderVisible = val;
+ },
+ visitTab(e) {
+ window.mrTabs?.clickTab(e);
+ },
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['gl-emoji'],
+ },
+};
+</script>
+
+<template>
+ <gl-intersection-observer
+ @appear="setStickyHeaderVisible(false)"
+ @disappear="setStickyHeaderVisible(true)"
+ >
+ <div
+ v-if="isStickyHeaderVisible"
+ class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-pt-3 gl-display-none gl-md-display-block"
+ >
+ <div
+ class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5"
+ :class="{ 'gl-max-w-container-xl': !isFluidLayout }"
+ >
+ <div class="gl-w-full gl-display-flex gl-align-items-center">
+ <status-box :initial-state="getNoteableData.state" issuable-type="merge_request" />
+ <p
+ v-safe-html:[$options.safeHtmlConfig]="title"
+ class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4"
+ ></p>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-sprintf :message="__('%{source} %{copyButton} into %{target}')">
+ <template #copyButton>
+ <clipboard-button
+ :text="getNoteableData.source_branch"
+ :title="__('Copy branch name')"
+ size="small"
+ category="tertiary"
+ tooltip-placement="bottom"
+ class="gl-m-0! gl-mx-1! js-source-branch-copy"
+ />
+ </template>
+ <template #source>
+ <gl-link
+ :title="getNoteableData.source_branch"
+ :href="getNoteableData.source_branch_path"
+ class="gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-text-truncate gl-max-w-26"
+ >
+ {{ getNoteableData.source_branch }}
+ </gl-link>
+ </template>
+ <template #target>
+ <gl-link
+ :title="getNoteableData.target_branch"
+ :href="getNoteableData.target_branch_path"
+ class="gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-text-truncate gl-max-w-26 gl-ml-2"
+ >
+ {{ getNoteableData.target_branch }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+ <div class="gl-w-full gl-display-flex">
+ <ul
+ class="merge-request-tabs nav-tabs nav nav-links gl-display-flex gl-flex-nowrap gl-m-0 gl-p-0 gl-border-b-0"
+ >
+ <li
+ v-for="(tab, index) in tabs"
+ :key="tab[0]"
+ :class="{ active: activeTab === tab[0] }"
+ >
+ <gl-link
+ :href="tab[2]"
+ :data-action="tab[0]"
+ class="gl-outline-0! gl-py-4!"
+ @click="visitTab"
+ >
+ {{ tab[1] }}
+ <gl-badge variant="muted" size="sm">
+ <template v-if="index === 0 && discussionCounter !== 0">
+ {{ discussionCounter }}
+ </template>
+ <template v-else>
+ {{ tab[3] }}
+ </template>
+ </gl-badge>
+ </gl-link>
+ </li>
+ </ul>
+ <div class="gl-display-none gl-lg-display-flex gl-align-items-center gl-ml-auto">
+ <discussion-counter blocks-merge hide-options />
+ <todo-widget
+ v-if="isSignedIn"
+ :issuable-id="issuableId"
+ :issuable-iid="issuableIid"
+ :full-path="projectPath"
+ issuable-type="merge_request"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue
index 59d2a2b29b3..5c3b969655b 100644
--- a/app/assets/javascripts/milestones/components/milestone_combobox.vue
+++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue
@@ -243,7 +243,7 @@ export default {
v-for="(item, idx) in extraLinks"
:key="idx"
:href="item.url"
- :is-check-item="true"
+ is-check-item
data-testid="milestone-combobox-extra-links"
>
{{ item.text }}
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 250d4b3c55f..e3fcdf716d4 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -391,7 +391,11 @@ export default {
};
</script>
<template>
- <div class="prometheus-graphs" data-qa-selector="prometheus_graphs">
+ <div
+ class="prometheus-graphs"
+ data-qa-selector="prometheus_graphs_content"
+ data-testid="prometheus-graphs"
+ >
<div>
<gl-alert
v-if="!isDeprecationNoticeDismissed"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index 3338635bf96..90d2498ac19 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -189,6 +189,7 @@ export default {
ref="monitorEnvironmentsDropdown"
class="flex-grow-1"
data-qa-selector="environments_dropdown"
+ data-testid="environments-dropdown"
toggle-class="dropdown-menu-toggle"
menu-class="monitor-environment-dropdown-menu"
:text="environmentDropdownText"
@@ -202,7 +203,7 @@ export default {
<gl-dropdown-item
v-for="environment in filteredEnvironments"
:key="environment.id"
- :is-check-item="true"
+ is-check-item
:is-checked="environment.name === currentEnvironmentName"
:href="getEnvironmentPath(environment.id)"
>
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
index 568c66cf152..7fae684315c 100644
--- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
+++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
@@ -86,7 +86,7 @@ export default {
<gl-dropdown-item
v-for="dashboard in starredDashboards"
:key="dashboard.path"
- :is-check-item="true"
+ is-check-item
:is-checked="dashboard.path === selectedDashboardPath"
@click="selectDashboard(dashboard)"
>
@@ -105,7 +105,7 @@ export default {
<gl-dropdown-item
v-for="dashboard in nonStarredDashboards"
:key="dashboard.path"
- :is-check-item="true"
+ is-check-item
:is-checked="dashboard.path === selectedDashboardPath"
@click="selectDashboard(dashboard)"
>
diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue
index 0b80043a92c..544fe10f26e 100644
--- a/app/assets/javascripts/monitoring/components/refresh_button.vue
+++ b/app/assets/javascripts/monitoring/components/refresh_button.vue
@@ -163,7 +163,7 @@ export default {
:text="dropdownText"
>
<gl-dropdown-item
- :is-check-item="true"
+ is-check-item
:is-checked="refreshInterval === null"
@click="removeRefreshInterval()"
>{{ __('Off') }}</gl-dropdown-item
@@ -172,7 +172,7 @@ export default {
<gl-dropdown-item
v-for="(option, i) in $options.refreshIntervals"
:key="i"
- :is-check-item="true"
+ is-check-item
:is-checked="isChecked(option)"
@click="setRefreshInterval(option)"
>{{ option.label }}</gl-dropdown-item
diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js
index c7bc626eb11..f20fea48084 100644
--- a/app/assets/javascripts/monitoring/format_date.js
+++ b/app/assets/javascripts/monitoring/format_date.js
@@ -1,4 +1,4 @@
-import dateFormat from 'dateformat';
+import dateFormat from '~/lib/dateformat';
export const timezones = {
/**
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index 7424c011052..297420bf94d 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -5,9 +5,8 @@ import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal';
import initDiffsApp from '../diffs';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
import MergeRequest from '../merge_request';
-import discussionCounter from '../notes/components/discussion_counter.vue';
+import DiscussionCounter from '../notes/components/discussion_counter.vue';
import initDiscussionFilters from '../notes/discussion_filters';
-import initSortDiscussions from '../notes/sort_discussions';
import initNotesApp from './init_notes';
export default function initMrNotes() {
@@ -38,7 +37,7 @@ export default function initMrNotes() {
el,
name: 'DiscussionCounter',
components: {
- discussionCounter,
+ DiscussionCounter,
},
store,
render(createElement) {
@@ -52,6 +51,5 @@ export default function initMrNotes() {
}
initDiscussionFilters(store);
- initSortDiscussions(store);
});
}
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index cf24d18c7b6..e4a7a7bd9fc 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -4,7 +4,7 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import store from '~/mr_notes/stores';
import discussionNavigator from '../notes/components/discussion_navigator.vue';
-import notesApp from '../notes/components/notes_app.vue';
+import NotesApp from '../notes/components/notes_app.vue';
import initWidget from '../vue_merge_request_widget';
export default () => {
@@ -13,7 +13,7 @@ export default () => {
el: '#js-vue-mr-discussions',
name: 'MergeRequestDiscussions',
components: {
- notesApp,
+ NotesApp,
},
store,
data() {
diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue
index 08a2c6952c8..ca6e6567f74 100644
--- a/app/assets/javascripts/nav/components/top_nav_app.vue
+++ b/app/assets/javascripts/nav/components/top_nav_app.vue
@@ -1,14 +1,18 @@
<script>
-import { GlNav, GlNavItemDropdown, GlDropdownForm } from '@gitlab/ui';
+import { GlNav, GlIcon, GlNavItemDropdown, GlDropdownForm, GlTooltipDirective } from '@gitlab/ui';
import TopNavDropdownMenu from './top_nav_dropdown_menu.vue';
export default {
components: {
+ GlIcon,
GlNav,
GlNavItemDropdown,
GlDropdownForm,
TopNavDropdownMenu,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
navData: {
type: Object,
@@ -21,15 +25,20 @@ export default {
<template>
<gl-nav class="navbar-sub-nav">
<gl-nav-item-dropdown
- :text="navData.activeTitle"
+ v-gl-tooltip.bottom="navData.menuTooltip"
data-qa-selector="navbar_dropdown"
- :data-qa-title="navData.activeTitle"
- icon="hamburger"
+ data-qa-title="Menu"
menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto! js-top-nav-dropdown-menu"
toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!"
no-flip
no-caret
>
+ <template #button-content>
+ <gl-icon name="hamburger" />
+ <span v-if="navData.menuTitle" class="gl-ml-3">
+ {{ navData.menuTitle }}
+ </span>
+ </template>
<gl-dropdown-form>
<top-nav-dropdown-menu
:primary="navData.primary"
diff --git a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
index b8555df53df..97e63c7324e 100644
--- a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
+++ b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
@@ -49,15 +49,26 @@ export default {
:class="getMenuSectionClasses(sectionIndex)"
data-testid="menu-section"
>
- <top-nav-menu-item
- v-for="(menuItem, menuItemIndex) in menuItems"
- :key="menuItem.id"
- :menu-item="menuItem"
- data-testid="menu-item"
- class="gl-w-full"
- :class="{ 'gl-mt-1': menuItemIndex > 0 }"
- @click="onClick(menuItem)"
- />
+ <template v-for="(menuItem, menuItemIndex) in menuItems">
+ <strong
+ v-if="menuItem.type == 'header'"
+ :key="menuItem.title"
+ class="gl-px-4 gl-py-2 gl-text-gray-900 gl-display-block"
+ :class="{ 'gl-pt-3!': menuItemIndex > 0 }"
+ data-testid="menu-header"
+ >
+ {{ menuItem.title }}
+ </strong>
+ <top-nav-menu-item
+ v-else
+ :key="menuItem.id"
+ :menu-item="menuItem"
+ data-testid="menu-item"
+ class="gl-w-full"
+ :class="{ 'gl-mt-1': menuItemIndex > 0 }"
+ @click="onClick(menuItem)"
+ />
+ </template>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue
index f5a6f3a9817..bc1bab62553 100644
--- a/app/assets/javascripts/notebook/cells/code.vue
+++ b/app/assets/javascripts/notebook/cells/code.vue
@@ -13,11 +13,6 @@ export default {
type: Object,
required: true,
},
- codeCssClass: {
- type: String,
- required: false,
- default: '',
- },
},
computed: {
rawInputCode() {
@@ -39,18 +34,12 @@ export default {
<template>
<div class="cell">
- <code-output
- :raw-code="rawInputCode"
- :count="cell.execution_count"
- :code-css-class="codeCssClass"
- type="input"
- />
+ <code-output :raw-code="rawInputCode" :count="cell.execution_count" type="input" />
<output-cell
v-if="hasOutput"
:count="cell.execution_count"
:outputs="outputs"
:metadata="cell.metadata"
- :code-css-class="codeCssClass"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue
index e1ef9aa6d79..64e801a7516 100644
--- a/app/assets/javascripts/notebook/cells/code/index.vue
+++ b/app/assets/javascripts/notebook/cells/code/index.vue
@@ -1,10 +1,11 @@
<script>
-import Prism from '../../lib/highlight';
+import CodeBlockHighlighted from '~/vue_shared/components/code_block_highlighted.vue';
import Prompt from '../prompt.vue';
export default {
name: 'CodeOutput',
components: {
+ CodeBlockHighlighted,
Prompt,
},
props: {
@@ -13,11 +14,6 @@ export default {
required: false,
default: 0,
},
- codeCssClass: {
- type: String,
- required: false,
- default: '',
- },
type: {
type: String,
required: true,
@@ -41,22 +37,21 @@ export default {
return type.charAt(0).toUpperCase() + type.slice(1);
},
- cellCssClass() {
- return {
- [this.codeCssClass]: true,
- 'jupyter-notebook-scrolled': this.metadata.scrolled,
- };
+ maxHeight() {
+ return this.metadata.scrolled ? '20rem' : 'initial';
},
},
- mounted() {
- Prism.highlightElement(this.$refs.code);
- },
};
</script>
<template>
<div :class="type">
<prompt :type="promptType" :count="count" />
- <pre ref="code" :class="cellCssClass" class="language-python" v-text="code"></pre>
+ <code-block-highlighted
+ language="python"
+ :code="code"
+ :max-height="maxHeight"
+ class="gl-border"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 8351ae7ced6..127e046b5a9 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -137,7 +137,7 @@ marked.setOptions({
export default {
components: {
- prompt: Prompt,
+ Prompt,
},
directives: {
SafeHtml,
diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue
index 065f5def83c..da7d83539d3 100644
--- a/app/assets/javascripts/notebook/cells/output/image.vue
+++ b/app/assets/javascripts/notebook/cells/output/image.vue
@@ -3,7 +3,7 @@ import Prompt from '../prompt.vue';
export default {
components: {
- prompt: Prompt,
+ Prompt,
},
props: {
count: {
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 5f7ef4a4377..88d01ffa659 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -6,11 +6,6 @@ import LatexOutput from './latex.vue';
export default {
props: {
- codeCssClass: {
- type: String,
- required: false,
- default: '',
- },
count: {
type: Number,
required: false,
@@ -96,7 +91,6 @@ export default {
:index="index"
:raw-code="rawCode(output)"
:metadata="metadata"
- :code-css-class="codeCssClass"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue
index 44dc1856e49..df9694b7cd8 100644
--- a/app/assets/javascripts/notebook/index.vue
+++ b/app/assets/javascripts/notebook/index.vue
@@ -11,11 +11,6 @@ export default {
type: Object,
required: true,
},
- codeCssClass: {
- type: String,
- required: false,
- default: '',
- },
},
computed: {
cells() {
@@ -52,7 +47,6 @@ export default {
v-for="(cell, index) in cells"
:key="index"
:cell="cell"
- :code-css-class="codeCssClass"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/lib/highlight.js b/app/assets/javascripts/notebook/lib/highlight.js
deleted file mode 100644
index 313aeecbd51..00000000000
--- a/app/assets/javascripts/notebook/lib/highlight.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import Prism from 'prismjs';
-import 'prismjs/components/prism-python';
-import 'prismjs/themes/prism.css';
-
-export default Prism;
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index bd5945a951b..bf35d5c3b25 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -14,7 +14,7 @@ import {
slugifyWithUnderscore,
} from '~/lib/utils/text_utility';
import { sprintf } from '~/locale';
-import markdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -25,8 +25,8 @@ import { COMMENT_FORM } from '../i18n';
import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
import CommentTypeDropdown from './comment_type_dropdown.vue';
-import discussionLockedWidget from './discussion_locked_widget.vue';
-import noteSignedOutWidget from './note_signed_out_widget.vue';
+import DiscussionLockedWidget from './discussion_locked_widget.vue';
+import NoteSignedOutWidget from './note_signed_out_widget.vue';
const { UNPROCESSABLE_ENTITY } = httpStatusCodes;
@@ -34,9 +34,9 @@ export default {
name: 'CommentForm',
i18n: COMMENT_FORM,
components: {
- noteSignedOutWidget,
- discussionLockedWidget,
- markdownField,
+ NoteSignedOutWidget,
+ DiscussionLockedWidget,
+ MarkdownField,
GlAlert,
GlButton,
TimelineEntryItem,
@@ -214,11 +214,7 @@ export default {
note: {
noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id,
- // Internal notes were identified as `confidential`
- // before we decided to treat them as _internal_
- // so now until API is updated we need to use `confidential`
- // in request payload.
- confidential: this.noteIsInternal,
+ internal: this.noteIsInternal,
note: this.note,
},
merge_request_diff_head_sha: this.getNoteableData.diff_head_sha,
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index 3cf47f42e0c..1b1923a90f7 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -4,16 +4,16 @@ import { escape } from 'lodash';
import { mapActions } from 'vuex';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
-import noteEditedText from './note_edited_text.vue';
-import noteHeader from './note_header.vue';
+import NoteEditedText from './note_edited_text.vue';
+import NoteHeader from './note_header.vue';
export default {
name: 'DiffDiscussionHeader',
components: {
GlAvatar,
GlAvatarLink,
- noteEditedText,
- noteHeader,
+ NoteEditedText,
+ NoteHeader,
},
directives: {
SafeHtml,
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 6f0745d4fb0..dcbf4a0e5d3 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -59,6 +59,7 @@ export default {
<resolve-discussion-button
v-if="discussion.resolvable"
data-qa-selector="resolve_discussion_button"
+ data-testid="resolve-discussion-button"
:is-resolving="isResolving"
:button-title="resolveButtonTitle"
@onClick="$emit('resolve')"
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index eedcb0c09d4..6521b86edbb 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -1,7 +1,16 @@
<script>
-import { GlTooltipDirective, GlButton, GlButtonGroup } from '@gitlab/ui';
+import {
+ GlTooltipDirective,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+} from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
+import { throttle } from 'lodash';
import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import discussionNavigation from '../mixins/discussion_navigation';
export default {
@@ -11,14 +20,23 @@ export default {
components: {
GlButton,
GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
},
- mixins: [discussionNavigation],
+ mixins: [glFeatureFlagsMixin(), discussionNavigation],
props: {
blocksMerge: {
type: Boolean,
required: true,
},
},
+ data() {
+ return {
+ jumpNext: throttle(this.jumpToNextDiscussion, 500),
+ jumpPrevious: throttle(this.jumpToPreviousDiscussion, 500),
+ };
+ },
computed: {
...mapGetters([
'getNoteableData',
@@ -54,27 +72,44 @@ export default {
<template>
<div
v-if="resolvableDiscussionsCount > 0"
+ id="discussionCounter"
ref="discussionCounter"
class="gl-display-flex discussions-counter"
>
<div
- class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-mr-3"
+ class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-mr-3 gl-min-h-7"
:class="{
'gl-bg-orange-50': blocksMerge && !allResolved,
'gl-bg-gray-50': !blocksMerge || allResolved,
- 'gl-pr-4': allResolved,
'gl-pr-2': !allResolved,
}"
data-testid="discussions-counter-text"
>
<template v-if="allResolved">
{{ __('All threads resolved!') }}
+ <gl-dropdown
+ size="small"
+ category="tertiary"
+ right
+ toggle-class="btn-icon"
+ class="gl-pt-0! gl-px-2 gl-h-full gl-ml-2"
+ >
+ <template #button-content>
+ <gl-icon name="ellipsis_v" class="mr-0" />
+ </template>
+ <gl-dropdown-item
+ data-testid="toggle-all-discussions-btn"
+ @click="handleExpandDiscussions"
+ >
+ {{ toggleThreadsLabel }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
<template v-else>
{{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
<gl-button-group class="gl-ml-3">
<gl-button
- v-gl-tooltip.hover
+ v-gl-tooltip:discussionCounter.hover.bottom
:title="__('Go to previous unresolved thread')"
:aria-label="__('Go to previous unresolved thread')"
class="discussion-previous-btn gl-rounded-base! gl-px-2!"
@@ -83,10 +118,10 @@ export default {
data-track-property="click_previous_unresolved_thread_top"
icon="chevron-lg-up"
category="tertiary"
- @click="jumpToPreviousDiscussion"
+ @click="jumpPrevious"
/>
<gl-button
- v-gl-tooltip.hover
+ v-gl-tooltip:discussionCounter.hover.bottom
:title="__('Go to next unresolved thread')"
:aria-label="__('Go to next unresolved thread')"
class="discussion-next-btn gl-rounded-base! gl-px-2!"
@@ -95,29 +130,33 @@ export default {
data-track-property="click_next_unresolved_thread_top"
icon="chevron-lg-down"
category="tertiary"
- @click="jumpToNextDiscussion"
+ @click="jumpNext"
/>
+ <gl-dropdown
+ size="small"
+ category="tertiary"
+ right
+ toggle-class="btn-icon"
+ class="gl-pt-0! gl-px-2"
+ >
+ <template #button-content>
+ <gl-icon name="ellipsis_v" class="mr-0" />
+ </template>
+ <gl-dropdown-item
+ data-testid="toggle-all-discussions-btn"
+ @click="handleExpandDiscussions"
+ >
+ {{ toggleThreadsLabel }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="resolveAllDiscussionsIssuePath && !allResolved"
+ :href="resolveAllDiscussionsIssuePath"
+ >
+ {{ __('Create issue to resolve all threads') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</gl-button-group>
</template>
</div>
- <gl-button-group>
- <gl-button
- v-gl-tooltip
- :title="toggleThreadsLabel"
- :aria-label="toggleThreadsLabel"
- class="toggle-all-discussions-btn"
- :icon="allExpanded ? 'collapse' : 'expand'"
- @click="handleExpandDiscussions"
- />
- <gl-button
- v-if="resolveAllDiscussionsIssuePath && !allResolved"
- v-gl-tooltip
- :href="resolveAllDiscussionsIssuePath"
- :title="__('Create issue to resolve all threads')"
- :aria-label="__('Create issue to resolve all threads')"
- class="new-issue-for-discussion discussion-create-issue-btn"
- icon="issue-new"
- />
- </gl-button-group>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 15887c2738d..8a42fb6bd85 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -2,6 +2,9 @@
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import {
DISCUSSION_FILTERS_DEFAULT_VALUE,
HISTORY_ONLY_FILTER_VALUE,
@@ -9,15 +12,25 @@ import {
DISCUSSION_TAB_LABEL,
DISCUSSION_FILTER_TYPES,
NOTE_UNDERSCORE,
+ ASC,
+ DESC,
} from '../constants';
import notesEventHub from '../event_hub';
+const SORT_OPTIONS = [
+ { key: DESC, text: __('Newest first'), cls: 'js-newest-first' },
+ { key: ASC, text: __('Oldest first'), cls: 'js-oldest-first' },
+];
+
export default {
+ SORT_OPTIONS,
components: {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
+ LocalStorageSync,
},
+ mixins: [Tracking.mixin()],
props: {
filters: {
type: Array,
@@ -39,11 +52,24 @@ export default {
};
},
computed: {
- ...mapGetters(['getNotesDataByProp', 'timelineEnabled', 'isLoading']),
+ ...mapGetters([
+ 'getNotesDataByProp',
+ 'timelineEnabled',
+ 'isLoading',
+ 'sortDirection',
+ 'persistSortOrder',
+ 'noteableType',
+ ]),
currentFilter() {
if (!this.currentValue) return this.filters[0];
return this.filters.find((filter) => filter.value === this.currentValue);
},
+ selectedSortOption() {
+ return SORT_OPTIONS.find(({ key }) => this.sortDirection === key);
+ },
+ sortStorageKey() {
+ return `sort_direction_${this.noteableType.toLowerCase()}`;
+ },
},
created() {
if (window.mrTabs) {
@@ -69,6 +95,7 @@ export default {
'setCommentsDisabled',
'setTargetNoteHash',
'setTimelineView',
+ 'setDiscussionSortDirection',
]),
selectFilter(value, persistFilter = true) {
const filter = parseInt(value, 10);
@@ -108,31 +135,73 @@ export default {
}
return DISCUSSION_FILTER_TYPES.HISTORY;
},
+ fetchSortedDiscussions(direction) {
+ if (this.isSortDropdownItemActive(direction)) {
+ return;
+ }
+
+ this.setDiscussionSortDirection({ direction });
+ this.track('change_discussion_sort_direction', { property: direction });
+ },
+ isSortDropdownItemActive(sortDir) {
+ return sortDir === this.sortDirection;
+ },
},
};
</script>
<template>
- <gl-dropdown
+ <div
v-if="displayFilters"
- id="discussion-filter-dropdown"
- class="full-width-mobile discussion-filter-container js-discussion-filter-container"
- data-qa-selector="discussion_filter_dropdown"
- :text="currentFilter.title"
- :disabled="isLoading"
+ id="discussion-preferences"
+ data-testid="discussion-preferences"
+ class="gl-display-inline-block gl-vertical-align-bottom full-width-mobile"
>
- <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper">
- <gl-dropdown-item
- :is-check-item="true"
- :is-checked="filter.value === currentValue"
- :class="{ 'is-active': filter.value === currentValue }"
- :data-filter-type="filterType(filter.value)"
- data-qa-selector="filter_menu_item"
- @click.prevent="selectFilter(filter.value)"
+ <local-storage-sync
+ :value="sortDirection"
+ :storage-key="sortStorageKey"
+ :persist="persistSortOrder"
+ as-string
+ @input="setDiscussionSortDirection({ direction: $event })"
+ />
+ <gl-dropdown
+ id="discussion-preferences-dropdown"
+ class="full-width-mobile"
+ data-qa-selector="discussion_preferences_dropdown"
+ text="Sort or filter"
+ :disabled="isLoading"
+ right
+ >
+ <div id="discussion-sort">
+ <gl-dropdown-item
+ v-for="{ text, key, cls } in $options.SORT_OPTIONS"
+ :key="text"
+ :class="cls"
+ is-check-item
+ :is-checked="isSortDropdownItemActive(key)"
+ @click="fetchSortedDiscussions(key)"
+ >
+ {{ text }}
+ </gl-dropdown-item>
+ </div>
+ <gl-dropdown-divider />
+ <div
+ id="discussion-filter"
+ class="discussion-filter-container js-discussion-filter-container"
>
- {{ filter.title }}
- </gl-dropdown-item>
- <gl-dropdown-divider v-if="filter.value === defaultValue" />
- </div>
- </gl-dropdown>
+ <gl-dropdown-item
+ v-for="filter in filters"
+ :key="filter.value"
+ is-check-item
+ :is-checked="filter.value === currentValue"
+ :class="{ 'is-active': filter.value === currentValue }"
+ :data-filter-type="filterType(filter.value)"
+ data-qa-selector="filter_menu_item"
+ @click.prevent="selectFilter(filter.value)"
+ >
+ {{ filter.title }}
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown>
+ </div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue
index c1e39f31bbb..03bdc7a2cc6 100644
--- a/app/assets/javascripts/notes/components/discussion_navigator.vue
+++ b/app/assets/javascripts/notes/components/discussion_navigator.vue
@@ -1,6 +1,7 @@
<script>
/* global Mousetrap */
import 'mousetrap';
+import { throttle } from 'lodash';
import {
keysFor,
MR_NEXT_UNRESOLVED_DISCUSSION,
@@ -11,12 +12,18 @@ import discussionNavigation from '~/notes/mixins/discussion_navigation';
export default {
mixins: [discussionNavigation],
+ data() {
+ return {
+ jumpToNext: throttle(() => this.jumpToNextDiscussion({ behavior: 'auto' }), 200),
+ jumpToPrevious: throttle(() => this.jumpToPreviousDiscussion({ behavior: 'auto' }), 200),
+ };
+ },
created() {
eventHub.$on('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion);
},
mounted() {
- Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNextDiscussion);
- Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPreviousDiscussion);
+ Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNext);
+ Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPrevious);
},
beforeDestroy() {
Mousetrap.unbind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION));
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index c7f293a219a..9806f8e5dc2 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui';
-import { mapActions, mapGetters } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import createFlash from '~/flash';
@@ -11,6 +11,7 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { splitCamelCase } from '~/lib/utils/text_utility';
import ReplyButton from './note_actions/reply_button.vue';
+import TimelineEventButton from './note_actions/timeline_event_button.vue';
export default {
i18n: {
@@ -23,6 +24,7 @@ export default {
components: {
GlIcon,
ReplyButton,
+ TimelineEventButton,
GlButton,
GlDropdownItem,
UserAccessRoleBadge,
@@ -133,7 +135,8 @@ export default {
},
},
computed: {
- ...mapGetters(['getUserDataByProp', 'getNoteableData']),
+ ...mapState(['isPromoteCommentToTimelineEventInProgress']),
+ ...mapGetters(['getUserDataByProp', 'getNoteableData', 'canUserAddIncidentTimelineEvents']),
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
@@ -199,7 +202,7 @@ export default {
},
},
methods: {
- ...mapActions(['toggleAwardRequest']),
+ ...mapActions(['toggleAwardRequest', 'promoteCommentToTimelineEvent']),
onEdit() {
this.$emit('handleEdit');
},
@@ -292,6 +295,12 @@ export default {
class="line-resolve-btn note-action-button"
@click="onResolve"
/>
+ <timeline-event-button
+ v-if="canUserAddIncidentTimelineEvents"
+ :note-id="noteId"
+ :is-promotion-in-progress="isPromoteCommentToTimelineEventInProgress"
+ @click-promote-comment-to-event="promoteCommentToTimelineEvent"
+ />
<emoji-picker
v-if="canAwardEmoji"
toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary"
diff --git a/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue b/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue
new file mode 100644
index 00000000000..4dd0c968282
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue
@@ -0,0 +1,49 @@
+<script>
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ buttonText: __('Add comment to incident timeline'),
+ addError: __('Error promoting the note to timeline event: %{error}'),
+ addGenericError: __('Something went wrong while promoting the note to timeline event.'),
+ },
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ noteId: {
+ type: [String, Number],
+ required: true,
+ },
+ isPromotionInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ handleButtonClick() {
+ this.$emit('click-promote-comment-to-event', {
+ noteId: this.noteId,
+ addError: this.$options.i18n.addError,
+ addGenericError: this.$options.i18n.addGenericError,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <span v-gl-tooltip :title="$options.i18n.buttonText">
+ <gl-button
+ category="tertiary"
+ icon="clock"
+ :aria-label="$options.i18n.buttonText"
+ :disabled="isPromotionInProgress"
+ @click="handleButtonClick"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index f1c41eea428..82c125b79ce 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -8,17 +8,17 @@ import { __ } from '~/locale';
import '~/behaviors/markdown/render_gfm';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import autosave from '../mixins/autosave';
-import noteAttachment from './note_attachment.vue';
-import noteAwardsList from './note_awards_list.vue';
-import noteEditedText from './note_edited_text.vue';
-import noteForm from './note_form.vue';
+import NoteAttachment from './note_attachment.vue';
+import NoteAwardsList from './note_awards_list.vue';
+import NoteEditedText from './note_edited_text.vue';
+import NoteForm from './note_form.vue';
export default {
components: {
- noteEditedText,
- noteAwardsList,
- noteAttachment,
- noteForm,
+ NoteEditedText,
+ NoteAwardsList,
+ NoteAttachment,
+ NoteForm,
Suggestions,
},
directives: {
@@ -71,7 +71,7 @@ export default {
return this.note.note;
},
saveButtonTitle() {
- return this.note.confidential ? __('Save internal note') : __('Save comment');
+ return this.note.internal ? __('Save internal note') : __('Save comment');
},
hasSuggestion() {
return this.note.suggestions && this.note.suggestions.length;
diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
index 03cbdf45ddd..e0c3ed0c67a 100644
--- a/app/assets/javascripts/notes/components/note_edited_text.vue
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -1,11 +1,11 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
-import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'EditedNoteText',
components: {
- timeAgoTooltip,
+ TimeAgoTooltip,
},
props: {
actionText: {
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 30579a8eb0d..b6ede10d02b 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -4,7 +4,7 @@ import { mapGetters, mapActions, mapState } from 'vuex';
import { getDraft, updateDraft } from '~/lib/utils/autosave';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import markdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
@@ -15,7 +15,7 @@ export default {
i18n: COMMENT_FORM,
name: 'NoteForm',
components: {
- markdownField,
+ MarkdownField,
CommentFieldLayout,
GlButton,
GlSprintf,
@@ -136,7 +136,7 @@ export default {
);
},
textareaPlaceholder() {
- return this.discussionNote?.confidential
+ return this.discussionNote?.internal
? this.$options.i18n.bodyPlaceholderInternal
: this.$options.i18n.bodyPlaceholder;
},
@@ -331,7 +331,7 @@ export default {
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<comment-field-layout
:noteable-data="getNoteableData"
- :is-internal-note="discussion.confidential"
+ :is-internal-note="discussion.internal"
>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
@@ -423,7 +423,7 @@ export default {
category="primary"
variant="confirm"
data-qa-selector="reply_comment_button"
- class="gl-mr-3 js-vue-issue-save js-comment-button"
+ class="gl-sm-mr-3 gl-xs-mb-3 js-vue-issue-save js-comment-button"
@click="handleUpdate()"
>
{{ saveButtonTitle }}
@@ -432,7 +432,7 @@ export default {
v-if="discussion.resolvable"
category="secondary"
variant="default"
- class="gl-mr-3 js-comment-resolve-button"
+ class="gl-sm-mr-3 gl-xs-mb-3 js-comment-resolve-button"
@click.prevent="handleUpdate(true)"
>
{{ resolveButtonTitle }}
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 9917249f0db..f700802d6bc 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -8,13 +8,14 @@ import {
} from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __, s__ } from '~/locale';
-import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
components: {
- timeAgoTooltip,
+ TimeAgoTooltip,
GitlabTeamMemberBadge: () =>
import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'),
GlIcon,
@@ -26,6 +27,7 @@ export default {
SafeHtml,
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
author: {
type: Object,
@@ -183,22 +185,35 @@ export default {
:data-user-id="author.id"
:data-username="author.username"
>
- <slot name="note-header-info"></slot>
+ <span
+ v-if="glFeatures.removeUserAttributesProjects || glFeatures.removeUserAttributesGroups"
+ class="note-header-author-name gl-font-weight-bold"
+ >
+ {{ authorName }}
+ </span>
<user-name-with-status
+ v-else
:name="authorName"
:availability="userAvailability(author)"
container-classes="note-header-author-name gl-font-weight-bold"
/>
</a>
<span
- v-if="authorStatus"
+ v-if="
+ authorStatus &&
+ !glFeatures.removeUserAttributesProjects &&
+ !glFeatures.removeUserAttributesGroups
+ "
ref="authorStatus"
v-safe-html:[$options.safeHtmlConfig]="authorStatus"
v-on="
authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {}
"
></span>
- <span class="text-nowrap author-username">
+ <span
+ v-if="!glFeatures.removeUserAttributesProjects && !glFeatures.removeUserAttributesGroups"
+ class="text-nowrap author-username"
+ >
<a
ref="authorUsernameLink"
class="author-username-link"
@@ -207,6 +222,7 @@ export default {
@mouseleave="handleUsernameMouseLeave"
><span class="note-headline-light">@{{ author.username }}</span>
</a>
+ <slot name="note-header-info"></slot>
<gitlab-team-member-badge v-if="author && author.is_gitlab_employee" />
</span>
</template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index c5d174ed890..afa5e39d8b0 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -10,25 +10,25 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { s__, __, sprintf } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
-import diffDiscussionHeader from './diff_discussion_header.vue';
-import diffWithNote from './diff_with_note.vue';
+import DiffDiscussionHeader from './diff_discussion_header.vue';
+import DiffWithNote from './diff_with_note.vue';
import DiscussionActions from './discussion_actions.vue';
import DiscussionNotes from './discussion_notes.vue';
-import noteForm from './note_form.vue';
-import noteSignedOutWidget from './note_signed_out_widget.vue';
+import NoteForm from './note_form.vue';
+import NoteSignedOutWidget from './note_signed_out_widget.vue';
export default {
name: 'NoteableDiscussion',
components: {
GlIcon,
- userAvatarLink,
- diffDiscussionHeader,
- noteSignedOutWidget,
- noteForm,
+ UserAvatarLink,
+ DiffDiscussionHeader,
+ NoteSignedOutWidget,
+ NoteForm,
DraftNote,
TimelineEntryItem,
DiscussionNotes,
@@ -96,7 +96,7 @@ export default {
return isLoggedIn();
},
commentType() {
- return this.discussion.confidential ? __('internal note') : __('comment');
+ return this.discussion.internal ? __('internal note') : __('comment');
},
autosaveKey() {
return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id);
@@ -108,7 +108,7 @@ export default {
return this.discussion.notes.slice(0, 1)[0];
},
saveButtonTitle() {
- return this.discussion.confidential ? __('Reply internally') : __('Reply');
+ return this.discussion.internal ? __('Reply internally') : __('Reply');
},
shouldShowJumpToNextDiscussion() {
return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion');
@@ -120,7 +120,7 @@ export default {
return !this.shouldRenderDiffs;
},
wrapperComponent() {
- return this.shouldRenderDiffs ? diffWithNote : 'div';
+ return this.shouldRenderDiffs ? DiffWithNote : 'div';
},
wrapperComponentProps() {
if (this.shouldRenderDiffs) {
@@ -269,6 +269,7 @@ export default {
<div class="timeline-content">
<div
:data-discussion-id="discussion.id"
+ :data-discussion-resolved="discussion.resolved"
class="discussion js-discussion-container"
data-qa-selector="discussion_content"
>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 875cfff74fe..e51969f95c7 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -22,16 +22,16 @@ import {
commentLineOptions,
formatLineRange,
} from './multiline_comment_utils';
-import noteActions from './note_actions.vue';
+import NoteActions from './note_actions.vue';
import NoteBody from './note_body.vue';
-import noteHeader from './note_header.vue';
+import NoteHeader from './note_header.vue';
export default {
name: 'NoteableNote',
components: {
GlSprintf,
- noteHeader,
- noteActions,
+ NoteHeader,
+ NoteActions,
NoteBody,
TimelineEntryItem,
GlAvatarLink,
@@ -109,7 +109,7 @@ export default {
return this.note.author;
},
commentType() {
- return this.note.confidential ? __('internal note') : __('comment');
+ return this.note.internal ? __('internal note') : __('comment');
},
classNameBindings() {
return {
@@ -259,7 +259,7 @@ export default {
});
const confirmed = await confirmAction(msg, {
primaryBtnVariant: 'danger',
- primaryBtnText: this.note.confidential ? __('Delete internal note') : __('Delete comment'),
+ primaryBtnText: this.note.internal ? __('Delete internal note') : __('Delete comment'),
});
if (confirmed) {
@@ -406,7 +406,7 @@ export default {
<template>
<timeline-entry-item
:id="noteAnchorId"
- :class="{ ...classNameBindings, 'internal-note': note.confidential }"
+ :class="{ ...classNameBindings, 'internal-note': note.internal }"
:data-award-url="note.toggle_award_path"
:data-note-id="note.id"
class="note note-wrapper"
@@ -440,7 +440,7 @@ export default {
</gl-avatar-link>
</div>
- <div v-else class="gl-float-left gl-pl-3 gl-mr-3 gl-md-pl-2 gl-md-pr-2">
+ <div v-else class="gl-float-left gl-pl-3 gl-md-pl-2">
<gl-avatar-link :href="author.path">
<gl-avatar
:src="author.avatar_url"
@@ -459,7 +459,7 @@ export default {
:author="author"
:created-at="note.created_at"
:note-id="note.id"
- :is-internal-note="note.confidential"
+ :is-internal-note="note.internal"
:noteable-type="noteableType"
>
<template #note-header-info>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 754c2917182..37bc8bad305 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -6,34 +6,34 @@ import { __ } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import draftNote from '~/batch_comments/components/draft_note.vue';
+import DraftNote from '~/batch_comments/components/draft_note.vue';
import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility';
-import placeholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
-import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
-import skeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue';
-import systemNote from '~/vue_shared/components/notes/system_note.vue';
+import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
+import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
+import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue';
+import SystemNote from '~/vue_shared/components/notes/system_note.vue';
import * as constants from '../constants';
import eventHub from '../event_hub';
-import commentForm from './comment_form.vue';
-import discussionFilterNote from './discussion_filter_note.vue';
-import noteableDiscussion from './noteable_discussion.vue';
-import noteableNote from './noteable_note.vue';
+import CommentForm from './comment_form.vue';
+import DiscussionFilterNote from './discussion_filter_note.vue';
+import NoteableDiscussion from './noteable_discussion.vue';
+import NoteableNote from './noteable_note.vue';
import SidebarSubscription from './sidebar_subscription.vue';
export default {
name: 'NotesApp',
components: {
- noteableNote,
- noteableDiscussion,
- systemNote,
- commentForm,
- placeholderNote,
- placeholderSystemNote,
- skeletonLoadingContainer,
- discussionFilterNote,
+ NoteableNote,
+ NoteableDiscussion,
+ SystemNote,
+ CommentForm,
+ PlaceholderNote,
+ PlaceholderSystemNote,
+ SkeletonLoadingContainer,
+ DiscussionFilterNote,
OrderedLayout,
SidebarSubscription,
- draftNote,
+ DraftNote,
TimelineEntryItem,
},
mixins: [glFeatureFlagsMixin()],
diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue
index 52dadc7b4c3..9fc11ff65d5 100644
--- a/app/assets/javascripts/notes/components/sidebar_subscription.vue
+++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue
@@ -3,7 +3,7 @@ import { mapActions } from 'vuex';
import { IssuableType } from '~/issues/constants';
import { fetchPolicies } from '~/lib/graphql';
import { confidentialityQueries } from '~/sidebar/constants';
-import { defaultClient as gqlClient } from '~/sidebar/graphql';
+import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client';
export default {
props: {
diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue
deleted file mode 100644
index bcc5d12b7c8..00000000000
--- a/app/assets/javascripts/notes/components/sort_discussion.vue
+++ /dev/null
@@ -1,76 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { mapActions, mapGetters } from 'vuex';
-import { __ } from '~/locale';
-import Tracking from '~/tracking';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { ASC, DESC } from '../constants';
-
-const SORT_OPTIONS = [
- { key: DESC, text: __('Newest first'), cls: 'js-newest-first' },
- { key: ASC, text: __('Oldest first'), cls: 'js-oldest-first' },
-];
-
-export default {
- SORT_OPTIONS,
- components: {
- GlDropdown,
- GlDropdownItem,
- LocalStorageSync,
- },
- mixins: [Tracking.mixin()],
- computed: {
- ...mapGetters(['sortDirection', 'persistSortOrder', 'noteableType']),
- selectedOption() {
- return SORT_OPTIONS.find(({ key }) => this.sortDirection === key);
- },
- dropdownText() {
- return this.selectedOption.text;
- },
- storageKey() {
- return `sort_direction_${this.noteableType.toLowerCase()}`;
- },
- },
- methods: {
- ...mapActions(['setDiscussionSortDirection']),
- fetchSortedDiscussions(direction) {
- if (this.isDropdownItemActive(direction)) {
- return;
- }
-
- this.setDiscussionSortDirection({ direction });
- this.track('change_discussion_sort_direction', { property: direction });
- },
- isDropdownItemActive(sortDir) {
- return sortDir === this.sortDirection;
- },
- },
-};
-</script>
-
-<template>
- <div
- data-testid="sort-discussion-filter"
- class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"
- >
- <local-storage-sync
- :value="sortDirection"
- :storage-key="storageKey"
- :persist="persistSortOrder"
- as-string
- @input="setDiscussionSortDirection({ direction: $event })"
- />
- <gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile">
- <gl-dropdown-item
- v-for="{ text, key, cls } in $options.SORT_OPTIONS"
- :key="key"
- :class="cls"
- :is-check-item="true"
- :is-checked="isDropdownItemActive(key)"
- @click="fetchSortedDiscussions(key)"
- >
- {{ text }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
-</template>
diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue
index e4d89f54652..8632eea5d8e 100644
--- a/app/assets/javascripts/notes/components/timeline_toggle.vue
+++ b/app/assets/javascripts/notes/components/timeline_toggle.vue
@@ -53,7 +53,6 @@ export default {
:selected="timelineEnabled"
:title="tooltip"
:aria-label="tooltip"
- class="gl-mr-3"
@click="toggleTimeline"
/>
</template>
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index a5f459c8910..88f438975f6 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -13,6 +13,7 @@ export const MERGED = 'merged';
export const ISSUE_NOTEABLE_TYPE = 'Issue';
export const EPIC_NOTEABLE_TYPE = 'Epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
+export const INCIDENT_NOTEABLE_TYPE = 'INCIDENT'; // TODO: check if value can be converted to `Incident`
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post';
export const DESCRIPTION_TYPE = 'changed the description';
@@ -31,6 +32,7 @@ export const NOTEABLE_TYPE_MAPPING = {
Issue: ISSUE_NOTEABLE_TYPE,
MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE,
Epic: EPIC_NOTEABLE_TYPE,
+ Incident: INCIDENT_NOTEABLE_TYPE,
};
export const DISCUSSION_FILTER_TYPES = {
diff --git a/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql b/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql
new file mode 100644
index 00000000000..c9df9cfd6d3
--- /dev/null
+++ b/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql
@@ -0,0 +1,8 @@
+mutation PromoteTimelineEvent($input: TimelineEventPromoteFromNoteInput!) {
+ timelineEventPromoteFromNote(input: $input) {
+ timelineEvent {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 19fa484d659..054a5bd36e2 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
-import notesApp from './components/notes_app.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import NotesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters';
-import initSortDiscussions from './sort_discussions';
import { store } from './stores';
import initTimelineToggle from './timeline';
@@ -16,7 +16,7 @@ export default () => {
el,
name: 'NotesRoot',
components: {
- notesApp,
+ NotesApp,
},
store,
data() {
@@ -40,6 +40,7 @@ export default () => {
username: parsedUserData.username,
avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url,
path: parsedUserData.path,
+ can_add_timeline_events: parseBoolean(notesDataset.canAddTimelineEvents),
};
}
@@ -61,6 +62,5 @@ export default () => {
});
initDiscussionFilters(store);
- initSortDiscussions(store);
initTimelineToggle(store);
};
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 45df91796fc..db5f9ebf3f0 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,5 +1,5 @@
import { mapGetters, mapActions, mapState } from 'vuex';
-import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils';
+import { scrollToElementWithContext, scrollToElement, contentTop } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
@@ -7,13 +7,14 @@ import eventHub from '../event_hub';
* @param {string} selector
* @returns {boolean}
*/
-function scrollTo(selector, { withoutContext = false } = {}) {
+function scrollTo(selector, { withoutContext = false, offset = 0 } = {}) {
const el = document.querySelector(selector);
const scrollFunction = withoutContext ? scrollToElement : scrollToElementWithContext;
if (el) {
scrollFunction(el, {
behavior: 'auto',
+ offset,
});
return true;
}
@@ -67,7 +68,10 @@ function diffsJump({ expandDiscussion }, id, firstNoteId) {
function discussionJump({ expandDiscussion }, id) {
const selector = `div.discussion[data-discussion-id="${id}"]`;
expandDiscussion({ discussionId: id });
- return scrollTo(selector, { withoutContext: true });
+ return scrollTo(selector, {
+ withoutContext: true,
+ offset: window.gon?.features?.movedMrSidebar ? -28 : 0,
+ });
}
/**
@@ -94,8 +98,6 @@ function jumpToDiscussion(self, discussion) {
if (activeTab === 'diffs' && isDiffDiscussion) {
diffsJump(self, id, firstNoteId);
- } else if (activeTab === 'show') {
- discussionJump(self, id);
} else {
switchToDiscussionsTabAndJumpTo(self, id);
}
@@ -105,11 +107,10 @@ function jumpToDiscussion(self, discussion) {
/**
* @param {object} self Component instance with mixin applied
* @param {function} fn Which function used to get the target discussion's id
- * @param {string} [discussionId=this.currentDiscussionId] Current discussion id, will be null if discussions have not been traversed yet
*/
-function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) {
+function handleDiscussionJump(self, fn) {
const isDiffView = window.mrTabs.currentAction === 'diffs';
- const targetId = fn(discussionId, isDiffView);
+ const targetId = fn(self.currentDiscussionId, isDiffView);
const discussion = self.getDiscussion(targetId);
const discussionFilePath = discussion?.diff_file?.file_path;
@@ -127,6 +128,70 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId)
});
}
+function getAllDiscussionElements() {
+ return Array.from(
+ document.querySelectorAll('[data-discussion-id]:not([data-discussion-resolved])'),
+ );
+}
+
+function hasReachedPageEnd() {
+ return document.body.scrollHeight <= Math.ceil(window.scrollY + window.innerHeight);
+}
+
+function findNextClosestVisibleDiscussion(discussionElements) {
+ const offsetHeight = contentTop();
+ let isActive;
+ const index = discussionElements.findIndex((element) => {
+ const { y } = element.getBoundingClientRect();
+ const visibleHorizontalOffset = Math.ceil(y) - offsetHeight;
+ // handle rect rounding errors
+ isActive = visibleHorizontalOffset < 2;
+ return visibleHorizontalOffset >= 0;
+ });
+ return [discussionElements[index], index, isActive];
+}
+
+function getNextDiscussion() {
+ const discussionElements = getAllDiscussionElements();
+ const firstDiscussion = discussionElements[0];
+ if (hasReachedPageEnd()) {
+ return firstDiscussion;
+ }
+ const [nextClosestDiscussion, index, isActive] = findNextClosestVisibleDiscussion(
+ discussionElements,
+ );
+ if (nextClosestDiscussion && !isActive) {
+ return nextClosestDiscussion;
+ }
+ const nextDiscussion = discussionElements[index + 1];
+ if (!nextClosestDiscussion || !nextDiscussion) {
+ return firstDiscussion;
+ }
+ return nextDiscussion;
+}
+
+function getPreviousDiscussion() {
+ const discussionElements = getAllDiscussionElements();
+ const lastDiscussion = discussionElements[discussionElements.length - 1];
+ const [, index] = findNextClosestVisibleDiscussion(discussionElements);
+ const previousDiscussion = discussionElements[index - 1];
+ if (previousDiscussion) {
+ return previousDiscussion;
+ }
+ return lastDiscussion;
+}
+
+function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
+ if (window.mrTabs.currentAction !== 'show') {
+ handleDiscussionJump(ctx, fn);
+ } else {
+ const discussion = getDiscussion();
+ const id = discussion.dataset.discussionId;
+ ctx.expandDiscussion({ discussionId: id });
+ scrollToElement(discussion, scrollOptions);
+ }
+}
+
export default {
computed: {
...mapGetters([
@@ -142,12 +207,22 @@ export default {
...mapActions(['expandDiscussion', 'setCurrentDiscussionId']),
...mapActions('diffs', ['scrollToFile']),
- jumpToNextDiscussion() {
- handleDiscussionJump(this, this.nextUnresolvedDiscussionId);
+ jumpToNextDiscussion(scrollOptions) {
+ handleJumpForBothPages(
+ getNextDiscussion,
+ this,
+ this.nextUnresolvedDiscussionId,
+ scrollOptions,
+ );
},
- jumpToPreviousDiscussion() {
- handleDiscussionJump(this, this.previousUnresolvedDiscussionId);
+ jumpToPreviousDiscussion(scrollOptions) {
+ handleJumpForBothPages(
+ getPreviousDiscussion,
+ this,
+ this.previousUnresolvedDiscussionId,
+ scrollOptions,
+ );
},
jumpToFirstUnresolvedDiscussion() {
@@ -157,13 +232,5 @@ export default {
})
.catch(() => {});
},
-
- /**
- * Go to the next discussion from the given discussionId
- * @param {String} discussionId The id we are jumping from
- */
- jumpToNextRelativeDiscussion(discussionId) {
- handleDiscussionJump(this, this.nextUnresolvedDiscussionId, discussionId);
- },
},
};
diff --git a/app/assets/javascripts/notes/sort_discussions.js b/app/assets/javascripts/notes/sort_discussions.js
deleted file mode 100644
index ca8df880fe4..00000000000
--- a/app/assets/javascripts/notes/sort_discussions.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Vue from 'vue';
-import SortDiscussion from './components/sort_discussion.vue';
-
-export default (store) => {
- const el = document.getElementById('js-vue-sort-issue-discussions');
-
- if (!el) return null;
-
- return new Vue({
- el,
- name: 'SortDiscussionRoot',
- store,
- render(createElement) {
- return createElement(SortDiscussion);
- },
- });
-};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 82417c9134b..fcef26d720c 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -6,6 +6,7 @@ import createFlash from '~/flash';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
+import toast from '~/vue_shared/plugins/global_toast';
import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql';
import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql';
@@ -18,6 +19,12 @@ import sidebarTimeTrackingEventHub from '~/sidebar/event_hub';
import TaskList from '~/task_list';
import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
import SidebarStore from '~/sidebar/stores/sidebar_store';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_NOTE } from '~/graphql_shared/constants';
+import notesEventHub from '../event_hub';
+
+import promoteTimelineEvent from '../graphql/promote_timeline_event.mutation.graphql';
+
import * as constants from '../constants';
import * as types from './mutation_types';
import * as utils from './utils';
@@ -226,6 +233,54 @@ export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes)
});
};
+export const promoteCommentToTimelineEvent = (
+ { commit },
+ { noteId, addError, addGenericError },
+) => {
+ commit(types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, true); // Set loading state
+ return utils.gqClient
+ .mutate({
+ mutation: promoteTimelineEvent,
+ variables: {
+ input: {
+ noteId: convertToGraphQLId(TYPE_NOTE, noteId),
+ },
+ },
+ })
+ .then(({ data = {} }) => {
+ const errors = data.timelineEventPromoteFromNote?.errors;
+ if (errors.length) {
+ const errorMessage = sprintf(addError, {
+ error: errors.join('. '),
+ });
+ throw new Error(errorMessage);
+ } else {
+ notesEventHub.$emit('comment-promoted-to-timeline-event');
+ toast(__('Comment added to the timeline.'));
+ }
+ })
+ .catch((error) => {
+ const message = error.message || addGenericError;
+
+ let captureError = false;
+ let errorObj = null;
+
+ if (message === addGenericError) {
+ captureError = true;
+ errorObj = error;
+ }
+
+ createFlash({
+ message,
+ captureError,
+ error: errorObj,
+ });
+ })
+ .finally(() => {
+ commit(types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, false); // Revert loading state
+ });
+};
+
export const replyToDiscussion = (
{ commit, state, getters, dispatch },
{ endpoint, data: reply },
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 1fe82d96435..6876220f75c 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -93,6 +93,13 @@ export const getUserDataByProp = (state) => (prop) => state.userData && state.us
export const descriptionVersions = (state) => state.descriptionVersions;
+export const canUserAddIncidentTimelineEvents = (state) => {
+ return (
+ state.userData.can_add_timeline_events &&
+ state.noteableData.type === constants.NOTEABLE_TYPE_MAPPING.Incident
+ );
+};
+
export const notesById = (state) =>
state.discussions.reduce((acc, note) => {
note.notes.every((n) => Object.assign(acc, { [n.id]: n }));
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index f779aad5679..7ba1f470b05 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -30,6 +30,7 @@ export default () => ({
isNotesFetched: false,
isLoading: true,
isLoadingDescriptionVersion: false,
+ isPromoteCommentToTimelineEventInProgress: false,
// holds endpoints and permissions provided through haml
notesData: {
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index e28a7bc5cdd..42df6bc0980 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -57,3 +57,6 @@ export const RECEIVE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DESCRIPTION_VERSION_ER
export const REQUEST_DELETE_DESCRIPTION_VERSION = 'REQUEST_DELETE_DESCRIPTION_VERSION';
export const RECEIVE_DELETE_DESCRIPTION_VERSION = 'RECEIVE_DELETE_DESCRIPTION_VERSION';
export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR';
+
+// Incidents
+export const SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS = 'SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 0823eacf1b7..83c15c12eac 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -425,4 +425,7 @@ export default {
[types.SET_DONE_FETCHING_BATCH_DISCUSSIONS](state, value) {
state.doneFetchingBatchDiscussions = value;
},
+ [types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS](state, value) {
+ state.isPromoteCommentToTimelineEventInProgress = value;
+ },
};
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue
new file mode 100644
index 00000000000..b55204de875
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
+import {
+ NO_ARTIFACTS_TITLE,
+ NO_TAGS_MATCHING_FILTERS_TITLE,
+ NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
+} from '~/packages_and_registries/harbor_registry/constants';
+import ArtifactsListRow from '~/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue';
+
+export default {
+ name: 'TagsList',
+ components: {
+ GlEmptyState,
+ ArtifactsListRow,
+ TagsLoader,
+ RegistryList,
+ },
+ inject: ['noContainersImage'],
+ props: {
+ artifacts: {
+ type: Array,
+ required: true,
+ },
+ filter: {
+ type: String,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ tags: [],
+ tagsPageInfo: {},
+ };
+ },
+ computed: {
+ hasNoTags() {
+ return this.artifacts.length === 0;
+ },
+ emptyStateTitle() {
+ return this.filter ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_ARTIFACTS_TITLE;
+ },
+ emptyStateDescription() {
+ return this.filter ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : '';
+ },
+ },
+ methods: {
+ fetchNextPage() {
+ this.$emit('next-page');
+ },
+ fetchPreviousPage() {
+ this.$emit('prev-page');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <tags-loader v-if="isLoading" />
+ <template v-else>
+ <gl-empty-state
+ v-if="hasNoTags"
+ :title="emptyStateTitle"
+ :svg-path="noContainersImage"
+ :description="emptyStateDescription"
+ class="gl-mx-auto gl-my-0"
+ />
+ <template v-else>
+ <registry-list
+ :pagination="pageInfo"
+ :items="artifacts"
+ :hidden-delete="true"
+ id-property="name"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ >
+ <template #default="{ item }">
+ <artifacts-list-row :artifact="item" />
+ </template>
+ </registry-list>
+ </template>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue
new file mode 100644
index 00000000000..b489f126f75
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { n__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import {
+ DIGEST_LABEL,
+ CREATED_AT_LABEL,
+ NOT_AVAILABLE_TEXT,
+ NOT_AVAILABLE_SIZE,
+} from '~/packages_and_registries/harbor_registry/constants';
+import { artifactPullCommand } from '~/packages_and_registries/harbor_registry/utils';
+
+export default {
+ name: 'TagsListRow',
+ components: {
+ GlSprintf,
+ GlIcon,
+ ListItem,
+ ClipboardButton,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['repositoryUrl', 'harborIntegrationProjectName'],
+ props: {
+ artifact: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ digestLabel: DIGEST_LABEL,
+ createdAtLabel: CREATED_AT_LABEL,
+ },
+ computed: {
+ formattedSize() {
+ return this.artifact.size
+ ? numberToHumanSize(Number(this.artifact.size))
+ : NOT_AVAILABLE_SIZE;
+ },
+ tagsCountText() {
+ const count = this.artifact?.tags.length ? this.artifact?.tags.length : 0;
+
+ return n__('%d tag', '%d tags', count);
+ },
+ shortDigest() {
+ // remove sha256: from the string, and show only the first 7 char
+ const PREFIX_LENGTH = 'sha256:'.length;
+ const DIGEST_LENGTH = 7;
+ return (
+ this.artifact.digest?.substring(PREFIX_LENGTH, PREFIX_LENGTH + DIGEST_LENGTH) ??
+ NOT_AVAILABLE_TEXT
+ );
+ },
+ getPullCommand() {
+ if (this.artifact?.digest) {
+ const { image } = this.$route.params;
+ return artifactPullCommand({
+ digest: this.artifact.digest,
+ imageName: image,
+ repositoryUrl: this.repositoryUrl,
+ harborProjectName: this.harborIntegrationProjectName,
+ });
+ }
+
+ return '';
+ },
+ linkTo() {
+ const { project, image } = this.$route.params;
+
+ return { name: 'tags', params: { project, image, digest: this.artifact.digest } };
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item v-bind="$attrs">
+ <template #left-primary>
+ <div class="gl-display-flex gl-align-items-center">
+ <router-link
+ class="gl-text-body gl-font-weight-bold gl-word-break-all"
+ data-testid="name"
+ :to="linkTo"
+ >
+ {{ artifact.digest }}
+ </router-link>
+ <clipboard-button
+ v-if="getPullCommand"
+ :title="getPullCommand"
+ :text="getPullCommand"
+ category="tertiary"
+ />
+ </div>
+ </template>
+
+ <template #left-secondary>
+ <span class="gl-mr-3" data-testid="size">
+ {{ formattedSize }}
+ </span>
+ <span id="tagsCount" class="gl-display-flex gl-align-items-center" data-testid="tags-count">
+ <gl-icon name="tag" class="gl-mr-2" />
+ {{ tagsCountText }}
+ </span>
+ </template>
+ <template #right-primary>
+ <span data-testid="time">
+ <gl-sprintf :message="$options.i18n.createdAtLabel">
+ <template #timeInfo>
+ <time-ago-tooltip :time="artifact.pushTime" />
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #right-secondary>
+ <span data-testid="digest">
+ <gl-sprintf :message="$options.i18n.digestLabel">
+ <template #imageId>{{ shortDigest }}</template>
+ </gl-sprintf>
+ </span>
+ <clipboard-button
+ v-if="artifact.digest"
+ :title="artifact.digest"
+ :text="artifact.digest"
+ category="tertiary"
+ />
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue
new file mode 100644
index 00000000000..bfb097601d5
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue
@@ -0,0 +1,47 @@
+<script>
+import { isEmpty } from 'lodash';
+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 {
+ ROOT_IMAGE_TEXT,
+ EMPTY_ARTIFACTS_LABEL,
+ artifactsLabel,
+} from '~/packages_and_registries/harbor_registry/constants/index';
+
+export default {
+ name: 'DetailsHeader',
+ components: { TitleArea, MetadataItem },
+ mixins: [timeagoMixin],
+ props: {
+ imagesDetail: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ artifactCountText() {
+ if (isEmpty(this.imagesDetail)) {
+ return EMPTY_ARTIFACTS_LABEL;
+ }
+ return artifactsLabel(this.imagesDetail.artifactCount);
+ },
+ repositoryFullName() {
+ return this.imagesDetail.name || ROOT_IMAGE_TEXT;
+ },
+ },
+};
+</script>
+
+<template>
+ <title-area>
+ <template #title>
+ <span data-testid="title">
+ {{ repositoryFullName }}
+ </span>
+ </template>
+ <template #metadata-tags-count>
+ <metadata-item icon="package" :text="artifactCountText" data-testid="artifacts-count" />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue
new file mode 100644
index 00000000000..ac1df5cf93f
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue
@@ -0,0 +1,68 @@
+<script>
+// Since app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue
+// can only handle two levels of breadcrumbs, but we have three levels here.
+// So we extended the registry_breadcrumb.vue component with harbor_registry_breadcrumb.vue to support multiple levels of breadcrumbs
+import { GlBreadcrumb, GlIcon } from '@gitlab/ui';
+import { isArray, last } from 'lodash';
+
+export default {
+ components: {
+ GlBreadcrumb,
+ GlIcon,
+ },
+ computed: {
+ rootRoute() {
+ return this.$router.options.routes.find((r) => r.meta.root);
+ },
+ isRootRoute() {
+ return this.$route.name === this.rootRoute.name;
+ },
+ currentRoute() {
+ const currentName = this.$route.meta.nameGenerator();
+ const currentHref = this.$route.meta.hrefGenerator();
+ let routeInfoList = [
+ {
+ text: currentName,
+ to: currentHref,
+ },
+ ];
+
+ if (isArray(currentName) && isArray(currentHref)) {
+ routeInfoList = currentName.map((name, index) => {
+ return {
+ text: name,
+ to: currentHref[index],
+ };
+ });
+ }
+
+ return routeInfoList;
+ },
+ isLoaded() {
+ return this.isRootRoute || last(this.currentRoute).text;
+ },
+ allCrumbs() {
+ let crumbs = [
+ {
+ text: this.rootRoute.meta.nameGenerator(),
+ to: this.rootRoute.path,
+ },
+ ];
+ if (!this.isRootRoute) {
+ crumbs = crumbs.concat(this.currentRoute);
+ }
+ return crumbs;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-breadcrumb :key="isLoaded" :items="allCrumbs">
+ <template #separator>
+ <span class="gl-mx-n5">
+ <gl-icon name="chevron-lg-right" :size="8" />
+ </span>
+ </template>
+ </gl-breadcrumb>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue
index 086b9c73d75..db66ebef937 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue
@@ -5,6 +5,7 @@ import {
HARBOR_REGISTRY_TITLE,
LIST_INTRO_TEXT,
imagesCountInfoText,
+ HARBOR_REGISTRY_HELP_PAGE_PATH,
} from '~/packages_and_registries/harbor_registry/constants';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
@@ -20,11 +21,6 @@ export default {
default: 0,
required: false,
},
- helpPagePath: {
- type: String,
- default: '',
- required: false,
- },
metadataLoading: {
type: Boolean,
required: false,
@@ -32,7 +28,7 @@ export default {
},
},
i18n: {
- HARBOR_REGISTRY_TITLE,
+ harborRegistryTitle: HARBOR_REGISTRY_TITLE,
},
computed: {
imagesCountText() {
@@ -40,7 +36,7 @@ export default {
return sprintf(pluralisedString, { count: this.imagesCount });
},
infoMessages() {
- return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }];
+ return [{ text: LIST_INTRO_TEXT, link: HARBOR_REGISTRY_HELP_PAGE_PATH }];
},
},
};
@@ -48,7 +44,7 @@ export default {
<template>
<title-area
- :title="$options.i18n.HARBOR_REGISTRY_TITLE"
+ :title="$options.i18n.harborRegistryTitle"
:info-messages="infoMessages"
:metadata-loading="metadataLoading"
>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue
index 258472fe16e..bfe0c250dd9 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue
@@ -1,15 +1,14 @@
<script>
-import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+import { GlIcon, GlSkeletonLoader } from '@gitlab/ui';
import { n__ } from '~/locale';
-
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import { getNameFromParams } from '~/packages_and_registries/harbor_registry/utils';
export default {
name: 'HarborListRow',
components: {
ClipboardButton,
- GlSprintf,
GlIcon,
ListItem,
GlSkeletonLoader,
@@ -26,19 +25,18 @@ export default {
},
},
computed: {
- id() {
- return this.item.id;
+ linkTo() {
+ const { projectName, imageName } = getNameFromParams(this.item.name);
+
+ return { name: 'details', params: { project: projectName, image: imageName } };
},
artifactCountText() {
return n__(
- 'HarborRegistry|%{count} Tag',
- 'HarborRegistry|%{count} Tags',
+ 'HarborRegistry|%d artifact',
+ 'HarborRegistry|%d artifacts',
this.item.artifactCount,
);
},
- imageName() {
- return this.item.name;
- },
},
};
</script>
@@ -50,9 +48,9 @@ export default {
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
data-qa-selector="registry_image_content"
- :to="{ name: 'details', params: { id } }"
+ :to="linkTo"
>
- {{ imageName }}
+ {{ item.name }}
</router-link>
<clipboard-button
v-if="item.location"
@@ -63,13 +61,9 @@ export default {
</template>
<template #left-secondary>
<template v-if="!metadataLoading">
- <span class="gl-display-flex gl-align-items-center" data-testid="tags-count">
- <gl-icon name="tag" class="gl-mr-2" />
- <gl-sprintf :message="artifactCountText">
- <template #count>
- {{ item.artifactCount }}
- </template>
- </gl-sprintf>
+ <span class="gl-display-flex gl-align-items-center" data-testid="artifacts-count">
+ <gl-icon name="package" class="gl-mr-2" />
+ {{ artifactCountText }}
</span>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue
new file mode 100644
index 00000000000..e7f6989c49f
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue
@@ -0,0 +1,54 @@
+<script>
+import { isEmpty } from 'lodash';
+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 {
+ EMPTY_TAG_LABEL,
+ tagsCountText,
+} from '~/packages_and_registries/harbor_registry/constants';
+
+export default {
+ name: 'TagsHeader',
+ components: {
+ TitleArea,
+ MetadataItem,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ artifactDetail: {
+ type: Object,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ tagsLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ tagCountText() {
+ if (isEmpty(this.pageInfo)) {
+ return EMPTY_TAG_LABEL;
+ }
+ return tagsCountText(this.pageInfo.total);
+ },
+ },
+};
+</script>
+
+<template>
+ <title-area :metadata-loading="tagsLoading">
+ <template #title>
+ <span class="gl-word-break-all" data-testid="title">
+ {{ artifactDetail.digest }}
+ </span>
+ </template>
+ <template #metadata-tags-count>
+ <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue
new file mode 100644
index 00000000000..b34d3a950c0
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
+import TagsListRow from '~/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue';
+import {
+ NO_ARTIFACTS_TITLE,
+ NO_TAGS_MATCHING_FILTERS_TITLE,
+ NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
+} from '~/packages_and_registries/harbor_registry/constants';
+
+export default {
+ name: 'TagsList',
+ components: {
+ GlEmptyState,
+ TagsLoader,
+ TagsListRow,
+ RegistryList,
+ },
+ inject: ['noContainersImage'],
+ props: {
+ tags: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ hasNoTags() {
+ return this.tags.length === 0;
+ },
+ emptyStateTitle() {
+ return this.filter ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_ARTIFACTS_TITLE;
+ },
+ emptyStateDescription() {
+ return this.filter ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : '';
+ },
+ },
+ methods: {
+ fetchNextPage() {
+ this.$emit('next-page');
+ },
+ fetchPreviousPage() {
+ this.$emit('prev-page');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <tags-loader v-if="isLoading" />
+ <gl-empty-state
+ v-else-if="hasNoTags"
+ :title="emptyStateTitle"
+ :svg-path="noContainersImage"
+ :description="emptyStateDescription"
+ class="gl-mx-auto gl-my-0"
+ />
+ <registry-list
+ v-else
+ :pagination="pageInfo"
+ :items="tags"
+ hidden-delete
+ id-property="name"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ >
+ <template #default="{ item }">
+ <tags-list-row :tag="item" />
+ </template>
+ </registry-list>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue
new file mode 100644
index 00000000000..63e046c1abc
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { CREATED_AT_LABEL } from '~/packages_and_registries/harbor_registry/constants';
+import { tagPullCommand } from '~/packages_and_registries/harbor_registry/utils';
+
+export default {
+ name: 'TagsListRow',
+ components: {
+ GlSprintf,
+ ListItem,
+ ClipboardButton,
+ TimeAgoTooltip,
+ },
+ inject: ['harborIntegrationProjectName', 'repositoryUrl'],
+ props: {
+ tag: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ createdAtLabel: CREATED_AT_LABEL,
+ },
+ methods: {
+ getPullCommand(tagName) {
+ if (tagName) {
+ const { image } = this.$route.params;
+
+ return tagPullCommand({
+ imageName: image,
+ tag: tagName,
+ repositoryUrl: this.repositoryUrl,
+ harborProjectName: this.harborIntegrationProjectName,
+ });
+ }
+
+ return '';
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item v-bind="$attrs">
+ <template #left-primary>
+ <div class="gl-display-flex gl-align-items-center">
+ <div
+ data-testid="name"
+ class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
+ >
+ {{ tag.name }}
+ </div>
+ <clipboard-button
+ :title="getPullCommand(tag.name)"
+ :text="getPullCommand(tag.name)"
+ category="tertiary"
+ />
+ </div>
+ </template>
+
+ <template #right-primary>
+ <span data-testid="time">
+ <gl-sprintf :message="$options.i18n.createdAtLabel">
+ <template #timeInfo>
+ <time-ago-tooltip :time="tag.pushTime" />
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js
index a7891821755..7f3c3da02b0 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js
@@ -1,4 +1,5 @@
import { s__, __ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const ROOT_IMAGE_TEXT = s__('HarborRegistry|Root image');
export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') };
@@ -16,14 +17,8 @@ export const SORT_FIELD_MAPPING = {
CREATED: CREATED_SORT_FIELD_KEY,
};
-/* eslint-disable @gitlab/require-i18n-strings */
-export const dockerBuildCommand = (repositoryUrl) => {
- return `docker build -t ${repositoryUrl} .`;
-};
-export const dockerPushCommand = (repositoryUrl) => {
- return `docker push ${repositoryUrl}`;
-};
-export const dockerLoginCommand = (registryHostUrlWithPort) => {
- return `docker login ${registryHostUrlWithPort}`;
-};
-/* eslint-enable @gitlab/require-i18n-strings */
+export const DEFAULT_PER_PAGE = 10;
+
+export const HARBOR_REGISTRY_HELP_PAGE_PATH = helpPagePath(
+ 'user/packages/harbor_container_registry/index',
+);
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
index b62c51bd208..5b4b85ec31e 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
@@ -1,22 +1,10 @@
-import { s__, __ } from '~/locale';
+import { s__, __, n__ } from '~/locale';
-export const UPDATED_AT = s__('HarborRegistry|Last updated %{time}');
-
-export const MISSING_OR_DELETED_IMAGE_TITLE = s__(
- 'HarborRegistry|The image repository could not be found.',
-);
-
-export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__(
- 'HarborRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.',
+export const FETCH_ARTIFACT_LIST_ERROR_MESSAGE = s__(
+ 'HarborRegistry|Something went wrong while fetching the artifact list.',
);
-export const NO_TAGS_TITLE = s__('HarborRegistry|This image has no active tags');
-
-export const NO_TAGS_MESSAGE = s__(
- `HarborRegistry|The last tag related to this image was recently removed.
-This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
-If you have any questions, contact your administrator.`,
-);
+export const NO_ARTIFACTS_TITLE = s__('HarborRegistry|This image has no artifacts');
export const NO_TAGS_MATCHING_FILTERS_TITLE = s__('HarborRegistry|The filter returned no results');
@@ -26,14 +14,24 @@ export const NO_TAGS_MATCHING_FILTERS_DESCRIPTION = s__(
export const DIGEST_LABEL = s__('HarborRegistry|Digest: %{imageId}');
export const CREATED_AT_LABEL = s__('HarborRegistry|Published %{timeInfo}');
-export const PUBLISHED_DETAILS_ROW_TEXT = s__(
- 'HarborRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}',
-);
-export const MANIFEST_DETAILS_ROW_TEST = s__('HarborRegistry|Manifest digest: %{digest}');
-export const CONFIGURATION_DETAILS_ROW_TEST = s__('HarborRegistry|Configuration digest: %{digest}');
-export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
- 'HarborRegistry|Invalid tag: missing manifest digest',
-);
export const NOT_AVAILABLE_TEXT = __('Not applicable.');
export const NOT_AVAILABLE_SIZE = __('0 bytes');
+
+export const TOKEN_TYPE_TAG_NAME = 'tag_name';
+
+export const FETCH_TAGS_ERROR_MESSAGE = s__(
+ 'HarborRegistry|Something went wrong while fetching the tags.',
+);
+
+export const TAG_LABEL = s__('HarborRegistry|Tag');
+export const EMPTY_TAG_LABEL = s__('HarborRegistry|-- tags');
+
+export const EMPTY_ARTIFACTS_LABEL = s__('HarborRegistry|-- artifacts');
+export const artifactsLabel = (count) => {
+ return n__('%d artifact', '%d artifacts', count);
+};
+
+export const tagsCountText = (count) => {
+ return n__('%d tag', '%d tags', count);
+};
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js
index a6cd59918ff..33950993125 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js
@@ -7,8 +7,13 @@ export const HARBOR_REGISTRY_TITLE = s__('HarborRegistry|Harbor Registry');
export const CONNECTION_ERROR_TITLE = s__('HarborRegistry|Harbor connection error');
export const CONNECTION_ERROR_MESSAGE = s__(
- `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`,
+ `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the documentation%{docLinkEnd}.`,
);
+
+export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__(
+ 'HarborRegistry|Something went wrong while fetching the repository list.',
+);
+
export const LIST_INTRO_TEXT = s__(
`HarborRegistry|With the Harbor Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`,
);
@@ -26,6 +31,13 @@ export const EMPTY_RESULT_MESSAGE = s__(
'HarborRegistry|To widen your search, change or remove the filters above.',
);
+export const EMPTY_IMAGES_TITLE = s__(
+ 'HarborRegistry|There are no harbor images stored for this project',
+);
+export const EMPTY_IMAGES_MESSAGE = s__(
+ 'HarborRegistry|With the Harbor Registry, every project can connect to a harbor space to store its Docker images.',
+);
+
export const SORT_FIELDS = [
{ orderBy: 'UPDATED', label: __('Updated') },
{ orderBy: 'CREATED', label: __('Created') },
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js
index ecfefead61a..6185e4c7bc6 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js
@@ -3,14 +3,8 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import PerformancePlugin from '~/performance/vue_performance_plugin';
import Translate from '~/vue_shared/translate';
-import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue';
+import RegistryBreadcrumb from '~/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue';
import { renderBreadcrumb } from '~/packages_and_registries/shared/utils';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import {
- dockerBuildCommand,
- dockerPushCommand,
- dockerLoginCommand,
-} from '~/packages_and_registries/harbor_registry/constants';
import createRouter from './router';
import HarborRegistryExplorer from './pages/index.vue';
@@ -35,13 +29,27 @@ export default (id) => {
return null;
}
- const { endpoint, connectionError, invalidPathError, isGroupPage, ...config } = el.dataset;
+ const {
+ endpoint,
+ connectionError,
+ invalidPathError,
+ isGroupPage,
+ noContainersImage,
+ containersErrorImage,
+ repositoryUrl,
+ harborIntegrationProjectName,
+ projectName,
+ } = el.dataset;
const breadCrumbState = Vue.observable({
name: '',
+ href: '',
updateName(value) {
this.name = value;
},
+ updateHref(value) {
+ this.href = value;
+ },
});
const router = createRouter(endpoint, breadCrumbState);
@@ -53,16 +61,15 @@ export default (id) => {
provide() {
return {
breadCrumbState,
- config: {
- ...config,
- connectionError: parseBoolean(connectionError),
- invalidPathError: parseBoolean(invalidPathError),
- isGroupPage: parseBoolean(isGroupPage),
- helpPagePath: helpPagePath('user/packages/container_registry/index'),
- },
- dockerBuildCommand: dockerBuildCommand(config.repositoryUrl),
- dockerPushCommand: dockerPushCommand(config.repositoryUrl),
- dockerLoginCommand: dockerLoginCommand(config.registryHostUrlWithPort),
+ endpoint,
+ connectionError: parseBoolean(connectionError),
+ invalidPathError: parseBoolean(invalidPathError),
+ isGroupPage: parseBoolean(isGroupPage),
+ repositoryUrl,
+ harborIntegrationProjectName,
+ projectName,
+ containersErrorImage,
+ noContainersImage,
};
},
render(createElement) {
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js b/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js
deleted file mode 100644
index 50c7df1483c..00000000000
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js
+++ /dev/null
@@ -1,200 +0,0 @@
-const mockRequestFn = (mockData) => {
- return new Promise((resolve) => {
- setTimeout(() => {
- resolve(mockData);
- }, 2000);
- });
-};
-export const harborListResponse = () => {
- const harborListResponseData = {
- repositories: [
- {
- artifactCount: 1,
- creationTime: '2022-03-02T06:35:53.205Z',
- id: 25,
- name: 'shao/flinkx',
- projectId: 21,
- pullCount: 0,
- updateTime: '2022-03-02T06:35:53.205Z',
- location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
- },
- {
- artifactCount: 1,
- creationTime: '2022-03-02T06:35:53.205Z',
- id: 26,
- name: 'shao/flinkx1',
- projectId: 21,
- pullCount: 0,
- updateTime: '2022-03-02T06:35:53.205Z',
- location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
- },
- {
- artifactCount: 1,
- creationTime: '2022-03-02T06:35:53.205Z',
- id: 27,
- name: 'shao/flinkx2',
- projectId: 21,
- pullCount: 0,
- updateTime: '2022-03-02T06:35:53.205Z',
- location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
- },
- ],
- totalCount: 3,
- pageInfo: {
- hasNextPage: false,
- hasPreviousPage: false,
- },
- };
-
- return mockRequestFn(harborListResponseData);
-};
-
-export const getHarborRegistryImageDetail = () => {
- const harborRegistryImageDetailData = {
- artifactCount: 1,
- creationTime: '2022-03-02T06:35:53.205Z',
- id: 25,
- name: 'shao/flinkx',
- projectId: 21,
- pullCount: 0,
- updateTime: '2022-03-02T06:35:53.205Z',
- location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
- tagsCount: 10,
- };
-
- return mockRequestFn(harborRegistryImageDetailData);
-};
-
-export const harborTagsResponse = () => {
- const harborTagsResponseData = {
- tags: [
- {
- digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
- name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
- revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255',
- shortRevision: 'f53bde3d4',
- createdAt: '2022-03-02T23:59:05+00:00',
- totalSize: '6623124',
- },
- {
- digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
- name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
- revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e',
- shortRevision: 'e1fe52d8b',
- createdAt: '2022-02-10T01:09:56+00:00',
- totalSize: '920760',
- },
- {
- digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
- name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
- revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f',
- shortRevision: 'c72770c6e',
- createdAt: '2021-12-22T04:48:48+00:00',
- totalSize: '48609053',
- },
- {
- digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
- name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
- revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a',
- shortRevision: '1ac2a4319',
- createdAt: '2022-03-09T11:02:27+00:00',
- totalSize: '35141894',
- },
- {
- digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
- name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
- revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c',
- shortRevision: 'cf8fee086',
- createdAt: '2022-01-21T11:31:43+00:00',
- totalSize: '48716070',
- },
- {
- digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
- name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
- revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15',
- shortRevision: '1a4b48198',
- createdAt: '2022-01-21T11:31:51+00:00',
- totalSize: '6623127',
- },
- {
- digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
- name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
- revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61',
- shortRevision: '03e2e2777',
- createdAt: '2022-03-02T23:58:20+00:00',
- totalSize: '911377',
- },
- {
- digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
- name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
- revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012',
- shortRevision: '350e78d60',
- createdAt: '2022-01-19T13:49:14+00:00',
- totalSize: '48710241',
- },
- {
- digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
- name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
- revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18',
- shortRevision: '76038370b',
- createdAt: '2022-01-24T12:56:22+00:00',
- totalSize: '280065',
- },
- {
- digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
- name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
- revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f',
- shortRevision: '3d4b49a7b',
- createdAt: '2022-02-17T17:37:52+00:00',
- totalSize: '48655767',
- },
- ],
- totalCount: 10,
- pageInfo: {
- hasNextPage: false,
- hasPreviousPage: true,
- },
- };
-
- return mockRequestFn(harborTagsResponseData);
-};
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
index e69de29bb2d..c6ab746b9f4 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
@@ -0,0 +1,156 @@
+<script>
+import { GlFilteredSearchToken } from '@gitlab/ui';
+import {
+ NAME_SORT_FIELD,
+ ROOT_IMAGE_TEXT,
+ DEFAULT_PER_PAGE,
+ FETCH_ARTIFACT_LIST_ERROR_MESSAGE,
+ TOKEN_TYPE_TAG_NAME,
+ TAG_LABEL,
+} from '~/packages_and_registries/harbor_registry/constants/index';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { createAlert } from '~/flash';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
+import ArtifactsList from '~/packages_and_registries/harbor_registry/components/details/artifacts_list.vue';
+import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
+import DetailsHeader from '~/packages_and_registries/harbor_registry/components/details/details_header.vue';
+import {
+ extractSortingDetail,
+ parseFilter,
+ formatPagination,
+} from '~/packages_and_registries/harbor_registry/utils';
+import { getHarborArtifacts } from '~/rest_api';
+
+export default {
+ name: 'HarborDetailsPage',
+ components: {
+ ArtifactsList,
+ TagsLoader,
+ DetailsHeader,
+ PersistedSearch,
+ },
+ inject: ['endpoint', 'breadCrumbState'],
+ searchConfig: { nameSortFields: [NAME_SORT_FIELD] },
+ tokens: [
+ {
+ type: TOKEN_TYPE_TAG_NAME,
+ icon: 'tag',
+ title: TAG_LABEL,
+ unique: true,
+ token: GlFilteredSearchToken,
+ operators: OPERATOR_IS_ONLY,
+ },
+ ],
+ data() {
+ return {
+ artifactsList: [],
+ pageInfo: {},
+ mutationLoading: false,
+ deleteAlertType: null,
+ isLoading: true,
+ filterString: '',
+ sorting: null,
+ };
+ },
+ computed: {
+ currentPage() {
+ return this.pageInfo.page || 1;
+ },
+ imagesDetail() {
+ return {
+ name: this.fullName,
+ artifactCount: this.pageInfo?.total || 0,
+ };
+ },
+ fullName() {
+ const { project, image } = this.$route.params;
+
+ if (project && image) {
+ return `${project}/${image}`;
+ }
+ return '';
+ },
+ },
+ mounted() {
+ this.updateBreadcrumb();
+ },
+ methods: {
+ updateBreadcrumb() {
+ const name = this.fullName || ROOT_IMAGE_TEXT;
+ this.breadCrumbState.updateName(name);
+ this.breadCrumbState.updateHref(this.$route.path);
+ },
+ handleSearchUpdate({ sort, filters }) {
+ this.sorting = sort;
+ this.filterString = parseFilter(filters, 'digest');
+
+ this.fetchArtifacts(1);
+ },
+ fetchPrevPage() {
+ const prevPageNum = this.currentPage - 1;
+ this.fetchArtifacts(prevPageNum);
+ },
+ fetchNextPage() {
+ const nextPageNum = this.currentPage + 1;
+ this.fetchArtifacts(nextPageNum);
+ },
+ fetchArtifacts(requestPage) {
+ this.isLoading = true;
+
+ const { orderBy, sort } = extractSortingDetail(this.sorting);
+ const sortOptions = `${orderBy} ${sort}`;
+
+ const { image } = this.$route.params;
+
+ const params = {
+ requestPath: this.endpoint,
+ repoName: image,
+ limit: DEFAULT_PER_PAGE,
+ page: requestPage,
+ sort: sortOptions,
+ search: this.filterString,
+ };
+
+ getHarborArtifacts(params)
+ .then((res) => {
+ this.pageInfo = formatPagination(res.headers);
+
+ this.artifactsList = (res?.data || []).map((artifact) => {
+ return convertObjectPropsToCamelCase(artifact);
+ });
+ })
+ .catch(() => {
+ createAlert({ message: FETCH_ARTIFACT_LIST_ERROR_MESSAGE });
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-my-3">
+ <details-header :images-detail="imagesDetail" />
+ <persisted-search
+ class="gl-mb-5"
+ :sortable-fields="$options.searchConfig.nameSortFields"
+ :default-order="$options.searchConfig.nameSortFields[0].orderBy"
+ default-sort="asc"
+ :tokens="$options.tokens"
+ @update="handleSearchUpdate"
+ />
+ <tags-loader v-if="isLoading" />
+ <artifacts-list
+ v-else
+ :filter="filterString"
+ :is-loading="isLoading"
+ :artifacts="artifactsList"
+ :page-info="pageInfo"
+ @prev-page="fetchPrevPage"
+ @next-page="fetchNextPage"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue
new file mode 100644
index 00000000000..1323d347d10
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue
@@ -0,0 +1,103 @@
+<script>
+import TagsHeader from '~/packages_and_registries/harbor_registry/components/tags/tags_header.vue';
+import TagsList from '~/packages_and_registries/harbor_registry/components/tags/tags_list.vue';
+import { getHarborTags } from '~/rest_api';
+import { FETCH_TAGS_ERROR_MESSAGE } from '~/packages_and_registries/harbor_registry/constants';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { createAlert } from '~/flash';
+import { formatPagination } from '~/packages_and_registries/harbor_registry/utils';
+
+export default {
+ name: 'HarborTagsPage',
+ components: {
+ TagsHeader,
+ TagsList,
+ },
+ inject: ['endpoint', 'breadCrumbState'],
+ data() {
+ return {
+ tagsLoading: false,
+ pageInfo: {},
+ tags: [],
+ };
+ },
+ computed: {
+ currentPage() {
+ return this.pageInfo?.page || 1;
+ },
+ artifactDetail() {
+ const { project, image, digest } = this.$route.params;
+
+ return {
+ project,
+ image,
+ digest,
+ };
+ },
+ },
+ mounted() {
+ this.updateBreadcrumb();
+ this.fetchTagsData();
+ },
+ methods: {
+ updateBreadcrumb() {
+ const artifactPath = `${this.artifactDetail.project}/${this.artifactDetail.image}`;
+ const nameList = [artifactPath, this.artifactDetail.digest];
+ const hrefList = [`/${artifactPath}`, this.$route.path];
+
+ this.breadCrumbState.updateName(nameList);
+ this.breadCrumbState.updateHref(hrefList);
+ },
+ fetchPrevPage() {
+ const prevPageNum = this.currentPage - 1;
+ this.fetchTagsData(prevPageNum);
+ },
+ fetchNextPage() {
+ const nextPageNum = this.currentPage + 1;
+ this.fetchTagsData(nextPageNum);
+ },
+ fetchTagsData(requestPage) {
+ this.tagsLoading = true;
+
+ const params = {
+ page: requestPage,
+ requestPath: this.endpoint,
+ repoName: this.artifactDetail.image,
+ digest: this.artifactDetail.digest,
+ };
+
+ getHarborTags(params)
+ .then((res) => {
+ this.pageInfo = formatPagination(res.headers);
+
+ this.tags = (res?.data || []).map((tagInfo) => {
+ return convertObjectPropsToCamelCase(tagInfo);
+ });
+ })
+ .catch(() => {
+ createAlert({ message: FETCH_TAGS_ERROR_MESSAGE });
+ })
+ .finally(() => {
+ this.tagsLoading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <tags-header
+ :artifact-detail="artifactDetail"
+ :page-info="pageInfo"
+ :tags-loading="tagsLoading"
+ />
+ <tags-list
+ :tags="tags"
+ :is-loading="tagsLoading"
+ :page-info="pageInfo"
+ @prev-page="fetchPrevPage"
+ @next-page="fetchNextPage"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
index 9c69059c968..931a99649cb 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
@@ -1,19 +1,32 @@
<script>
import { GlEmptyState, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui';
-import { escape } from 'lodash';
import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue';
-import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import {
+ extractSortingDetail,
+ formatPagination,
+ parseFilter,
+ dockerBuildCommand,
+ dockerPushCommand,
+ dockerLoginCommand,
+} from '~/packages_and_registries/harbor_registry/utils';
+import { createAlert } from '~/flash';
import {
SORT_FIELDS,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
+ DEFAULT_PER_PAGE,
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
+ EMPTY_IMAGES_TITLE,
+ EMPTY_IMAGES_MESSAGE,
+ HARBOR_REGISTRY_HELP_PAGE_PATH,
} from '~/packages_and_registries/harbor_registry/constants';
import Tracking from '~/tracking';
-import { harborListResponse } from '../mock_api';
+import { getHarborRepositoriesList } from '~/rest_api';
export default {
name: 'HarborListPage',
@@ -31,19 +44,28 @@ export default {
),
},
mixins: [Tracking.mixin()],
- inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
+ inject: [
+ 'endpoint',
+ 'repositoryUrl',
+ 'harborIntegrationProjectName',
+ 'projectName',
+ 'isGroupPage',
+ 'connectionError',
+ 'invalidPathError',
+ 'containersErrorImage',
+ 'noContainersImage',
+ ],
loader: {
repeat: 10,
width: 1000,
height: 40,
},
i18n: {
- CONNECTION_ERROR_TITLE,
- CONNECTION_ERROR_MESSAGE,
- EMPTY_RESULT_TITLE,
- EMPTY_RESULT_MESSAGE,
+ connectionErrorTitle: CONNECTION_ERROR_TITLE,
+ connectionErrorMessage: CONNECTION_ERROR_MESSAGE,
},
searchConfig: SORT_FIELDS,
+ helpPagePath: HARBOR_REGISTRY_HELP_PAGE_PATH,
data() {
return {
images: [],
@@ -56,42 +78,81 @@ export default {
};
},
computed: {
+ dockerCommand() {
+ return {
+ build: dockerBuildCommand({
+ repositoryUrl: this.repositoryUrl,
+ harborProjectName: this.harborIntegrationProjectName,
+ projectName: this.projectName,
+ }),
+ push: dockerPushCommand({
+ repositoryUrl: this.repositoryUrl,
+ harborProjectName: this.harborIntegrationProjectName,
+ projectName: this.projectName,
+ }),
+ login: dockerLoginCommand(this.repositoryUrl),
+ };
+ },
showCommands() {
- return !this.isLoading && !this.config?.isGroupPage && this.images?.length;
+ return !this.isLoading && !this.isGroupPage && this.images?.length;
},
showConnectionError() {
- return this.config.connectionError || this.config.invalidPathError;
+ return this.connectionError || this.invalidPathError;
+ },
+ currentPage() {
+ return this.pageInfo.page || 1;
+ },
+ emptyStateTexts() {
+ return {
+ title: this.name ? EMPTY_RESULT_TITLE : EMPTY_IMAGES_TITLE,
+ message: this.name ? EMPTY_RESULT_MESSAGE : EMPTY_IMAGES_MESSAGE,
+ };
},
},
methods: {
- fetchHarborImages() {
- // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777
+ fetchHarborImages(requestPage) {
this.isLoading = true;
- harborListResponse()
+ const { orderBy, sort } = extractSortingDetail(this.sorting);
+ const sortOptions = `${orderBy} ${sort}`;
+
+ const params = {
+ requestPath: this.endpoint,
+ limit: DEFAULT_PER_PAGE,
+ search: this.name,
+ page: requestPage,
+ sort: sortOptions,
+ };
+
+ getHarborRepositoriesList(params)
.then((res) => {
- this.images = res?.repositories || [];
- this.totalCount = res?.totalCount || 0;
- this.pageInfo = res?.pageInfo || {};
+ this.images = (res?.data || []).map((item) => {
+ return convertObjectPropsToCamelCase(item);
+ });
+ const pagination = formatPagination(res.headers);
+
+ this.totalCount = pagination?.total || 0;
+ this.pageInfo = pagination;
+
this.isLoading = false;
})
- .catch(() => {});
+ .catch(() => {
+ createAlert({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ });
},
handleSearchUpdate({ sort, filters }) {
this.sorting = sort;
+ this.name = parseFilter(filters, 'name');
- const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM);
- this.name = escape(search?.value?.data);
-
- this.fetchHarborImages();
+ this.fetchHarborImages(1);
},
fetchPrevPage() {
- // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777
- this.fetchHarborImages();
+ const prevPageNum = this.currentPage - 1;
+ this.fetchHarborImages(prevPageNum);
},
fetchNextPage() {
- // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777
- this.fetchHarborImages();
+ const nextPageNum = this.currentPage + 1;
+ this.fetchHarborImages(nextPageNum);
},
},
};
@@ -101,14 +162,14 @@ export default {
<div>
<gl-empty-state
v-if="showConnectionError"
- :title="$options.i18n.CONNECTION_ERROR_TITLE"
- :svg-path="config.containersErrorImage"
+ :title="$options.i18n.connectionErrorTitle"
+ :svg-path="containersErrorImage"
>
<template #description>
<p>
- <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE">
+ <gl-sprintf :message="$options.i18n.connectionErrorMessage">
<template #docLink="{ content }">
- <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank">
+ <gl-link :href="$options.helpPagePath" target="_blank">
{{ content }}
</gl-link>
</template>
@@ -117,17 +178,13 @@ export default {
</template>
</gl-empty-state>
<template v-else>
- <harbor-list-header
- :metadata-loading="isLoading"
- :images-count="totalCount"
- :help-page-path="config.helpPagePath"
- >
+ <harbor-list-header :metadata-loading="isLoading" :images-count="totalCount">
<template #commands>
<cli-commands
v-if="showCommands"
- :docker-build-command="dockerBuildCommand"
- :docker-push-command="dockerPushCommand"
- :docker-login-command="dockerLoginCommand"
+ :docker-build-command="dockerCommand.build"
+ :docker-push-command="dockerCommand.push"
+ :docker-login-command="dockerCommand.login"
/>
</template>
</harbor-list-header>
@@ -152,26 +209,24 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <template v-if="images.length > 0 || name">
- <harbor-list
- v-if="images.length"
- :images="images"
- :meta-data-loading="isLoading"
- :page-info="pageInfo"
- @prev-page="fetchPrevPage"
- @next-page="fetchNextPage"
- />
- <gl-empty-state
- v-else
- :svg-path="config.noContainersImage"
- data-testid="emptySearch"
- :title="$options.i18n.EMPTY_RESULT_TITLE"
- >
- <template #description>
- {{ $options.i18n.EMPTY_RESULT_MESSAGE }}
- </template>
- </gl-empty-state>
- </template>
+ <harbor-list
+ v-if="images.length"
+ :images="images"
+ :metadata-loading="isLoading"
+ :page-info="pageInfo"
+ @prev-page="fetchPrevPage"
+ @next-page="fetchNextPage"
+ />
+ <gl-empty-state
+ v-else
+ :svg-path="noContainersImage"
+ data-testid="emptySearch"
+ :title="emptyStateTexts.title"
+ >
+ <template #description>
+ {{ emptyStateTexts.message }}
+ </template>
+ </gl-empty-state>
</template>
</template>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js
index 572dd382be3..5a792e30c62 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js
@@ -3,6 +3,7 @@ import VueRouter from 'vue-router';
import { HARBOR_REGISTRY_TITLE } from './constants/index';
import List from './pages/list.vue';
import Details from './pages/details.vue';
+import HarborTags from './pages/harbor_tags.vue';
Vue.use(VueRouter);
@@ -22,10 +23,20 @@ export default function createRouter(base, breadCrumbState) {
},
{
name: 'details',
- path: '/:id',
+ path: '/:project/:image',
component: Details,
meta: {
nameGenerator: () => breadCrumbState.name,
+ hrefGenerator: () => breadCrumbState.href,
+ },
+ },
+ {
+ name: 'tags',
+ path: '/:project/:image/:digest',
+ component: HarborTags,
+ meta: {
+ nameGenerator: () => breadCrumbState.name,
+ hrefGenerator: () => breadCrumbState.href,
},
},
],
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js
new file mode 100644
index 00000000000..13df303cffe
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js
@@ -0,0 +1,84 @@
+import { isFinite } from 'lodash';
+import {
+ SORT_FIELD_MAPPING,
+ TOKEN_TYPE_TAG_NAME,
+} from '~/packages_and_registries/harbor_registry/constants';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
+
+export const extractSortingDetail = (parsedSorting = '') => {
+ const [orderBy, sortOrder] = parsedSorting.split('_');
+ if (orderBy && sortOrder) {
+ return {
+ orderBy: SORT_FIELD_MAPPING[orderBy],
+ sort: sortOrder.toLowerCase(),
+ };
+ }
+
+ return {
+ orderBy: '',
+ sort: '',
+ };
+};
+
+export const parseFilter = (filters = [], defaultPrefix = '') => {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ const prefixMap = {
+ [FILTERED_SEARCH_TERM]: `${defaultPrefix}=`,
+ [TOKEN_TYPE_TAG_NAME]: 'tags=',
+ };
+ /* eslint-enable @gitlab/require-i18n-strings */
+ const filterList = [];
+ filters.forEach((i) => {
+ if (i.value?.data) {
+ const filterVal = i.value?.data;
+ const prefix = prefixMap[i.type];
+ const filterString = `${prefix}${filterVal}`;
+
+ filterList.push(filterString);
+ }
+ });
+
+ return filterList.join(',');
+};
+
+export const getNameFromParams = (fullName) => {
+ const names = fullName.split('/');
+ return {
+ projectName: names[0] || '',
+ imageName: names[1] || '',
+ };
+};
+
+export const formatPagination = (headers) => {
+ const pagination = parseIntPagination(normalizeHeaders(headers)) || {};
+
+ if (pagination.nextPage || pagination.previousPage) {
+ pagination.hasNextPage = isFinite(pagination.nextPage);
+ pagination.hasPreviousPage = isFinite(pagination.previousPage);
+ }
+
+ return pagination;
+};
+
+/* eslint-disable @gitlab/require-i18n-strings */
+export const dockerBuildCommand = ({ repositoryUrl, harborProjectName, projectName = '' }) => {
+ return `docker build -t ${repositoryUrl}/${harborProjectName}/${projectName} .`;
+};
+
+export const dockerPushCommand = ({ repositoryUrl, harborProjectName, projectName = '' }) => {
+ return `docker push ${repositoryUrl}/${harborProjectName}/${projectName}`;
+};
+
+export const dockerLoginCommand = (repositoryUrl) => {
+ return `docker login ${repositoryUrl}`;
+};
+
+export const artifactPullCommand = ({ repositoryUrl, harborProjectName, imageName, digest }) => {
+ return `docker pull ${repositoryUrl}/${harborProjectName}/${imageName}@${digest}`;
+};
+
+export const tagPullCommand = ({ repositoryUrl, harborProjectName, imageName, tag }) => {
+ return `docker pull ${repositoryUrl}/${harborProjectName}/${imageName}:${tag}`;
+};
+/* eslint-enable @gitlab/require-i18n-strings */
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
index 425fb4596fd..fd099ee4e69 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
@@ -114,7 +114,7 @@ export default {
deleteModalContent: s__(
`PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`,
),
- deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`),
+ deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`),
deleteFileModalContent: s__(
`PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`,
),
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
index 28bfb82093c..e45b88bc6d5 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
@@ -84,14 +84,14 @@ export default {
},
},
i18n: {
- deleteFile: __('Delete file'),
+ deleteFile: __('Delete asset'),
},
};
</script>
<template>
<div>
- <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
+ <h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3>
<gl-table
:fields="filesTableHeaderFields"
:items="filesTableRows"
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
index a465fea0b74..dab4a051d0c 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
@@ -98,7 +98,7 @@ export default {
</div>
<template v-else>
- <div data-qa-selector="packages-table">
+ <div data-testid="packages-table">
<packages-list-row
v-for="packageEntity in list"
:key="packageEntity.id"
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue
index 3c6b8344c34..cc52235eaf3 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue
@@ -78,7 +78,7 @@ export default {
</script>
<template>
- <list-item data-qa-selector="package_row" :disabled="disabledRow">
+ <list-item data-testid="package-row" :disabled="disabledRow">
<template #left-primary>
<div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
<gl-link
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue
index 122d444e859..f581469b12b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue
@@ -14,16 +14,17 @@ import NpmInstallation from './npm_installation.vue';
import NugetInstallation from './nuget_installation.vue';
import PypiInstallation from './pypi_installation.vue';
+const components = {
+ [PACKAGE_TYPE_CONAN]: ConanInstallation,
+ [PACKAGE_TYPE_MAVEN]: MavenInstallation,
+ [PACKAGE_TYPE_NPM]: NpmInstallation,
+ [PACKAGE_TYPE_NUGET]: NugetInstallation,
+ [PACKAGE_TYPE_PYPI]: PypiInstallation,
+ [PACKAGE_TYPE_COMPOSER]: ComposerInstallation,
+};
+
export default {
name: 'InstallationCommands',
- components: {
- [PACKAGE_TYPE_CONAN]: ConanInstallation,
- [PACKAGE_TYPE_MAVEN]: MavenInstallation,
- [PACKAGE_TYPE_NPM]: NpmInstallation,
- [PACKAGE_TYPE_NUGET]: NugetInstallation,
- [PACKAGE_TYPE_PYPI]: PypiInstallation,
- [PACKAGE_TYPE_COMPOSER]: ComposerInstallation,
- },
props: {
packageEntity: {
type: Object,
@@ -32,7 +33,7 @@ export default {
},
computed: {
installationComponent() {
- return this.$options.components[this.packageEntity.packageType];
+ return components[this.packageEntity.packageType];
},
},
};
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
index b872294d2cf..8eb8654cddd 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
@@ -139,7 +139,7 @@ export default {
},
},
i18n: {
- deleteFile: __('Delete file'),
+ deleteFile: __('Delete asset'),
deleteSelected: s__('PackageRegistry|Delete selected'),
moreActionsText: __('More actions'),
},
@@ -149,7 +149,7 @@ export default {
<template>
<div class="gl-pt-6">
<div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
- <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
+ <h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3>
<gl-button
v-if="canDelete"
:disabled="isLoading || !areFilesSelected"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
index 04faff1a75b..7a000aca0f2 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
@@ -90,7 +90,7 @@ export default {
</script>
<template>
- <list-item data-qa-selector="package_row">
+ <list-item data-testid="package-row">
<template #left-primary>
<div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
<router-link
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
index a6ac2eb1b2b..e84f181e9b2 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
@@ -151,7 +151,7 @@ export default {
@primaryAction="showConfirmationModal"
>{{ $options.i18n.errorMessageBodyAlert }}</gl-alert
>
- <div data-qa-selector="packages-table">
+ <div data-testid="packages-table">
<packages-list-row
v-for="packageEntity in list"
:key="packageEntity.id"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index 5b2a347a4ee..06a04ee248a 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -79,10 +79,10 @@ export const TRACKING_LABEL_PACKAGE_HISTORY = 'package_history';
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
- 'PackageRegistry|Something went wrong while deleting the package file.',
+ 'PackageRegistry|Something went wrong while deleting the package asset.',
);
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
- 'PackageRegistry|Package file deleted successfully',
+ 'PackageRegistry|Package asset deleted successfully',
);
export const DELETE_PACKAGE_FILES_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package assets.',
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
index e83962bb608..c10fc914d56 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
@@ -256,7 +256,7 @@ export default {
deleteModalContent: s__(
`PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`,
),
- deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`),
+ deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`),
deleteFileModalContent: s__(
`PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`,
),
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
deleted file mode 100644
index 51a97aead49..00000000000
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<script>
-import { GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
-import { isEqual } from 'lodash';
-
-import {
- DUPLICATES_TOGGLE_LABEL,
- DUPLICATES_SETTING_EXCEPTION_TITLE,
- DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
-} from '~/packages_and_registries/settings/group/constants';
-
-export default {
- name: 'DuplicatesSettings',
- i18n: {
- DUPLICATES_TOGGLE_LABEL,
- DUPLICATES_SETTING_EXCEPTION_TITLE,
- DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
- },
- components: {
- GlToggle,
- GlFormGroup,
- GlFormInput,
- },
- props: {
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
- duplicatesAllowed: {
- type: Boolean,
- default: false,
- required: false,
- },
- duplicateExceptionRegex: {
- type: String,
- default: '',
- required: false,
- },
- duplicateExceptionRegexError: {
- type: String,
- default: '',
- required: false,
- },
- modelNames: {
- type: Object,
- required: true,
- validator(value) {
- return isEqual(Object.keys(value), ['allowed', 'exception']);
- },
- },
- toggleQaSelector: {
- type: String,
- required: false,
- default: null,
- },
- labelQaSelector: {
- type: String,
- required: false,
- default: null,
- },
- },
- computed: {
- isExceptionRegexValid() {
- return !this.duplicateExceptionRegexError;
- },
- },
- methods: {
- update(type, value) {
- this.$emit('update', { [type]: value });
- },
- },
-};
-</script>
-
-<template>
- <form>
- <gl-toggle
- :data-qa-selector="toggleQaSelector"
- :label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
- :value="!duplicatesAllowed"
- :disabled="loading"
- @change="update(modelNames.allowed, !$event)"
- />
- <gl-form-group
- v-if="!duplicatesAllowed"
- class="gl-mt-4"
- :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE"
- label-size="sm"
- :state="isExceptionRegexValid"
- :invalid-feedback="duplicateExceptionRegexError"
- :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND"
- label-for="maven-duplicated-settings-regex-input"
- >
- <gl-form-input
- id="maven-duplicated-settings-regex-input"
- :disabled="loading"
- size="lg"
- :value="duplicateExceptionRegex"
- @change="update(modelNames.exception, $event)"
- />
- </gl-form-group>
- </form>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue
new file mode 100644
index 00000000000..9ac1673dbf3
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue
@@ -0,0 +1,79 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+
+import {
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
+} from '~/packages_and_registries/settings/group/constants';
+
+export default {
+ name: 'ExceptionsInput',
+ i18n: {
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
+ },
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ duplicatesAllowed: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ duplicateExceptionRegex: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ duplicateExceptionRegexError: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ id: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isExceptionRegexValid() {
+ return !this.duplicateExceptionRegexError;
+ },
+ },
+ methods: {
+ update(type, value) {
+ this.$emit('update', { [type]: value });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ class="gl-mb-0"
+ :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE"
+ label-sr-only
+ :invalid-feedback="duplicateExceptionRegexError"
+ :label-for="id"
+ >
+ <gl-form-input
+ :id="id"
+ :disabled="duplicatesAllowed || loading"
+ size="lg"
+ :value="duplicateExceptionRegex"
+ :state="isExceptionRegexValid"
+ @change="update(name, $event)"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue
deleted file mode 100644
index e5f63fe8d0d..00000000000
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-import { s__ } from '~/locale';
-import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
-
-export default {
- name: 'GenericSettings',
- components: {
- SettingsTitles,
- },
- i18n: {
- title: s__('PackageRegistry|Generic'),
- subTitle: s__('PackageRegistry|Settings for Generic packages'),
- },
- modelNames: {
- allowed: 'genericDuplicatesAllowed',
- exception: 'genericDuplicateExceptionRegex',
- },
-};
-</script>
-
-<template>
- <div>
- <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" />
- <slot :model-names="$options.modelNames"></slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
deleted file mode 100644
index a1cbd695f34..00000000000
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-import { s__ } from '~/locale';
-import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
-
-export default {
- name: 'MavenSettings',
- components: {
- SettingsTitles,
- },
- i18n: {
- title: s__('PackageRegistry|Maven'),
- subTitle: s__('PackageRegistry|Settings for Maven packages'),
- },
- modelNames: {
- allowed: 'mavenDuplicatesAllowed',
- exception: 'mavenDuplicateExceptionRegex',
- },
-};
-</script>
-
-<template>
- <div>
- <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" />
- <slot :model-names="$options.modelNames"></slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
index abb9f02d290..de087a8fcc5 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
@@ -1,27 +1,50 @@
<script>
-import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
-import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
-import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
+import { GlTableLite, GlToggle } from '@gitlab/ui';
import {
+ GENERIC_PACKAGE_FORMAT,
+ MAVEN_PACKAGE_FORMAT,
+ PACKAGE_FORMATS_TABLE_HEADER,
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_TOGGLE_LABEL,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
+import ExceptionsInput from '~/packages_and_registries/settings/group/components/exceptions_input.vue';
export default {
name: 'PackageSettings',
i18n: {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_TOGGLE_LABEL,
},
+ tableHeaderFields: [
+ {
+ key: 'packageFormat',
+ label: PACKAGE_FORMATS_TABLE_HEADER,
+ thClass: 'gl-bg-gray-10!',
+ },
+ {
+ key: 'allowDuplicates',
+ label: DUPLICATES_TOGGLE_LABEL,
+ thClass: 'gl-bg-gray-10!',
+ },
+ {
+ key: 'exceptions',
+ label: DUPLICATES_SETTING_EXCEPTION_TITLE,
+ thClass: 'gl-bg-gray-10!',
+ },
+ ],
components: {
SettingsBlock,
- MavenSettings,
- GenericSettings,
- DuplicatesSettings,
+ GlTableLite,
+ GlToggle,
+ ExceptionsInput,
},
inject: ['groupPath'],
props: {
@@ -40,6 +63,37 @@ export default {
errors: {},
};
},
+ computed: {
+ tableRows() {
+ return [
+ {
+ id: 'maven-duplicated-settings-regex-input',
+ format: MAVEN_PACKAGE_FORMAT,
+ duplicatesAllowed: this.packageSettings.mavenDuplicatesAllowed,
+ duplicateExceptionRegex: this.packageSettings.mavenDuplicateExceptionRegex,
+ duplicateExceptionRegexError: this.errors.mavenDuplicateExceptionRegex,
+ modelNames: {
+ allowed: 'mavenDuplicatesAllowed',
+ exception: 'mavenDuplicateExceptionRegex',
+ },
+ testid: 'maven-settings',
+ dataQaSelector: 'allow_duplicates_toggle',
+ },
+ {
+ id: 'generic-duplicated-settings-regex-input',
+ format: GENERIC_PACKAGE_FORMAT,
+ duplicatesAllowed: this.packageSettings.genericDuplicatesAllowed,
+ duplicateExceptionRegex: this.packageSettings.genericDuplicateExceptionRegex,
+ duplicateExceptionRegexError: this.errors.genericDuplicateExceptionRegex,
+ modelNames: {
+ allowed: 'genericDuplicatesAllowed',
+ exception: 'genericDuplicateExceptionRegex',
+ },
+ testid: 'generic-settings',
+ },
+ ];
+ },
+ },
methods: {
async updateSettings(payload) {
this.errors = {};
@@ -79,6 +133,9 @@ export default {
this.$emit('error');
}
},
+ update(type, value) {
+ this.updateSettings({ [type]: value });
+ },
},
};
</script>
@@ -92,32 +149,40 @@ export default {
</span>
</template>
<template #default>
- <maven-settings data-testid="maven-settings">
- <template #default="{ modelNames }">
- <duplicates-settings
- :duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
- :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
- :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
- :model-names="modelNames"
- :loading="isLoading"
- toggle-qa-selector="reject_duplicates_toggle"
- label-qa-selector="reject_duplicates_label"
- @update="updateSettings"
- />
- </template>
- </maven-settings>
- <generic-settings class="gl-mt-6" data-testid="generic-settings">
- <template #default="{ modelNames }">
- <duplicates-settings
- :duplicates-allowed="packageSettings.genericDuplicatesAllowed"
- :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
- :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
- :model-names="modelNames"
- :loading="isLoading"
- @update="updateSettings"
- />
- </template>
- </generic-settings>
+ <form>
+ <gl-table-lite
+ :fields="$options.tableHeaderFields"
+ :items="tableRows"
+ stacked="sm"
+ :tbody-tr-attr="(item) => ({ 'data-testid': item.testid })"
+ >
+ <template #cell(packageFormat)="{ item }">
+ <span class="gl-md-pt-3">{{ item.format }}</span>
+ </template>
+ <template #cell(allowDuplicates)="{ item }">
+ <gl-toggle
+ :data-qa-selector="item.dataQaSelector"
+ :label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
+ :value="item.duplicatesAllowed"
+ :disabled="isLoading"
+ label-position="hidden"
+ class="gl-align-items-flex-end gl-sm-align-items-flex-start"
+ @change="update(item.modelNames.allowed, $event)"
+ />
+ </template>
+ <template #cell(exceptions)="{ item }">
+ <exceptions-input
+ :id="item.id"
+ :duplicates-allowed="item.duplicatesAllowed"
+ :duplicate-exception-regex="item.duplicateExceptionRegex"
+ :duplicate-exception-regex-error="item.duplicateExceptionRegexError"
+ :name="item.modelNames.exception"
+ :loading="isLoading"
+ @update="updateSettings"
+ />
+ </template>
+ </gl-table-lite>
+ </form>
</template>
</settings-block>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
deleted file mode 100644
index 1e93875c1e3..00000000000
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-export default {
- name: 'SettingsTitle',
- props: {
- title: {
- type: String,
- required: true,
- },
- subTitle: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
-</script>
-
-<template>
- <div>
- <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200 gl-pb-3">
- {{ title }}
- </h5>
- <p v-if="subTitle">{{ subTitle }}</p>
- <slot></slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index 34764663892..2dd6d3f76f6 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -5,10 +5,11 @@ export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Duplicate packages')
export const PACKAGE_SETTINGS_DESCRIPTION = s__(
'PackageRegistry|Allow packages with the same name and version to be uploaded to the registry. The newest version of a package is always used when installing.',
);
+export const PACKAGE_FORMATS_TABLE_HEADER = s__('PackageRegistry|Package formats');
+export const MAVEN_PACKAGE_FORMAT = s__('PackageRegistry|Maven');
+export const GENERIC_PACKAGE_FORMAT = s__('PackageRegistry|Generic');
-export const DUPLICATES_TOGGLE_LABEL = s__(
- 'PackageRegistry|Reject packages with the same name and version',
-);
+export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions');
export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
'PackageRegistry|Publish packages if their name or version matches this regex.',
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue
new file mode 100644
index 00000000000..72e68aca070
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { isEqual, get, isEmpty } from 'lodash';
+import {
+ CONTAINER_CLEANUP_POLICY_TITLE,
+ CONTAINER_CLEANUP_POLICY_DESCRIPTION,
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ UNAVAILABLE_FEATURE_TITLE,
+ UNAVAILABLE_FEATURE_INTRO_TEXT,
+ UNAVAILABLE_USER_FEATURE_TEXT,
+ UNAVAILABLE_ADMIN_FEATURE_TEXT,
+} from '~/packages_and_registries/settings/project/constants';
+import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
+
+import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue';
+
+export default {
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ ContainerExpirationPolicyForm,
+ },
+ inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'],
+ i18n: {
+ CONTAINER_CLEANUP_POLICY_TITLE,
+ CONTAINER_CLEANUP_POLICY_DESCRIPTION,
+ UNAVAILABLE_FEATURE_TITLE,
+ UNAVAILABLE_FEATURE_INTRO_TEXT,
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ },
+ apollo: {
+ containerExpirationPolicy: {
+ query: expirationPolicyQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update: (data) => data.project?.containerExpirationPolicy,
+ result({ data }) {
+ this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) };
+ },
+ error(e) {
+ this.fetchSettingsError = e;
+ },
+ },
+ },
+ data() {
+ return {
+ fetchSettingsError: false,
+ containerExpirationPolicy: null,
+ workingCopy: {},
+ };
+ },
+ computed: {
+ isEnabled() {
+ return this.containerExpirationPolicy || this.enableHistoricEntries;
+ },
+ showDisabledFormMessage() {
+ return !this.isEnabled && !this.fetchSettingsError;
+ },
+ unavailableFeatureMessage() {
+ return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
+ },
+ isEdited() {
+ if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) {
+ return false;
+ }
+ return !isEqual(this.containerExpirationPolicy, this.workingCopy);
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-testid="container-expiration-policy-project-settings">
+ <h4 data-testid="title">{{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</h4>
+ <p data-testid="description">
+ <gl-sprintf :message="$options.i18n.CONTAINER_CLEANUP_POLICY_DESCRIPTION">
+ <template #link="{ content }">
+ <gl-link :href="helpPagePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <container-expiration-policy-form
+ v-if="isEnabled"
+ v-model="workingCopy"
+ :is-loading="$apollo.queries.containerExpirationPolicy.loading"
+ :is-edited="isEdited"
+ />
+ <template v-else>
+ <gl-alert
+ v-if="showDisabledFormMessage"
+ :dismissible="false"
+ :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE"
+ variant="tip"
+ >
+ {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }}
+
+ <gl-sprintf :message="unavailableFeatureMessage">
+ <template #link="{ content }">
+ <gl-link :href="adminSettingsPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false">
+ <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" />
+ </gl-alert>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
index 1c44d2bc38b..b003b6aeb6b 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
@@ -1,9 +1,11 @@
<script>
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { isEqual, get, isEmpty } from 'lodash';
+import { GlAlert, GlSprintf, GlLink, GlCard, GlButton } from '@gitlab/ui';
import {
CONTAINER_CLEANUP_POLICY_TITLE,
CONTAINER_CLEANUP_POLICY_DESCRIPTION,
+ CONTAINER_CLEANUP_POLICY_EDIT_RULES,
+ CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION,
+ CONTAINER_CLEANUP_POLICY_SET_RULES,
FETCH_SETTINGS_ERROR_MESSAGE,
UNAVAILABLE_FEATURE_TITLE,
UNAVAILABLE_FEATURE_INTRO_TEXT,
@@ -13,20 +15,29 @@ import {
import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
-import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue';
-
export default {
components: {
SettingsBlock,
GlAlert,
GlSprintf,
GlLink,
- ContainerExpirationPolicyForm,
+ GlCard,
+ GlButton,
},
- inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'],
+ inject: [
+ 'projectPath',
+ 'isAdmin',
+ 'adminSettingsPath',
+ 'enableHistoricEntries',
+ 'helpPagePath',
+ 'cleanupSettingsPath',
+ ],
i18n: {
CONTAINER_CLEANUP_POLICY_TITLE,
CONTAINER_CLEANUP_POLICY_DESCRIPTION,
+ CONTAINER_CLEANUP_POLICY_EDIT_RULES,
+ CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION,
+ CONTAINER_CLEANUP_POLICY_SET_RULES,
UNAVAILABLE_FEATURE_TITLE,
UNAVAILABLE_FEATURE_INTRO_TEXT,
FETCH_SETTINGS_ERROR_MESSAGE,
@@ -40,9 +51,6 @@ export default {
};
},
update: (data) => data.project?.containerExpirationPolicy,
- result({ data }) {
- this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) };
- },
error(e) {
this.fetchSettingsError = e;
},
@@ -52,29 +60,25 @@ export default {
return {
fetchSettingsError: false,
containerExpirationPolicy: null,
- workingCopy: {},
};
},
computed: {
- isDisabled() {
- return !(this.containerExpirationPolicy || this.enableHistoricEntries);
+ isCleanupEnabled() {
+ return this.containerExpirationPolicy?.enabled ?? false;
+ },
+ isEnabled() {
+ return this.containerExpirationPolicy || this.enableHistoricEntries;
},
showDisabledFormMessage() {
- return this.isDisabled && !this.fetchSettingsError;
+ return !this.isEnabled && !this.fetchSettingsError;
},
unavailableFeatureMessage() {
return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
},
- isEdited() {
- if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) {
- return false;
- }
- return !isEqual(this.containerExpirationPolicy, this.workingCopy);
- },
- },
- methods: {
- restoreOriginal() {
- this.workingCopy = { ...this.containerExpirationPolicy };
+ cleanupRulesButtonText() {
+ return this.isCleanupEnabled
+ ? this.$options.i18n.CONTAINER_CLEANUP_POLICY_EDIT_RULES
+ : this.$options.i18n.CONTAINER_CLEANUP_POLICY_SET_RULES;
},
},
};
@@ -93,13 +97,19 @@ export default {
</span>
</template>
<template #default>
- <container-expiration-policy-form
- v-if="!isDisabled"
- v-model="workingCopy"
- :is-loading="$apollo.queries.containerExpirationPolicy.loading"
- :is-edited="isEdited"
- @reset="restoreOriginal"
- />
+ <gl-card v-if="isEnabled">
+ <p data-testid="description">
+ {{ $options.i18n.CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION }}
+ </p>
+ <gl-button
+ data-testid="rules-button"
+ :href="cleanupSettingsPath"
+ category="secondary"
+ variant="confirm"
+ >
+ {{ cleanupRulesButtonText }}
+ </gl-button>
+ </gl-card>
<template v-else>
<gl-alert
v-if="showDisabledFormMessage"
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
index ae2d5f4fbc5..11d8732426d 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
@@ -1,8 +1,9 @@
<script>
import { GlCard, GlButton, GlSprintf } from '@gitlab/ui';
+import { objectToQuery, visitUrl } from '~/lib/utils/url_utility';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
- UPDATE_SETTINGS_SUCCESS_MESSAGE,
+ SHOW_SETUP_SUCCESS_ALERT,
SET_CLEANUP_POLICY_BUTTON,
KEEP_HEADER_TEXT,
KEEP_INFO_TEXT,
@@ -37,7 +38,7 @@ export default {
ExpirationRunText,
},
mixins: [Tracking.mixin()],
- inject: ['projectPath'],
+ inject: ['projectPath', 'projectSettingsPath'],
props: {
value: {
type: Object,
@@ -95,10 +96,10 @@ export default {
return Object.values(this.localErrors).every((error) => error);
},
isSubmitButtonDisabled() {
- return !this.fieldsAreValid || this.showLoadingIcon;
+ return !this.isEdited || !this.fieldsAreValid || this.showLoadingIcon;
},
isCancelButtonDisabled() {
- return !this.isEdited || this.isLoading || this.mutationLoading;
+ return this.isLoading || this.mutationLoading;
},
isFieldDisabled() {
return this.showLoadingIcon || !this.value.enabled;
@@ -119,12 +120,6 @@ export default {
findDefaultOption(option) {
return this.value[option] || this.$options.formOptions[option].find((f) => f.default)?.key;
},
- reset() {
- this.track('reset_form');
- this.apiErrors = {};
- this.localErrors = {};
- this.$emit('reset');
- },
setApiErrors(response) {
this.apiErrors = response.graphQLErrors.reduce((acc, curr) => {
curr.extensions.problems.forEach((item) => {
@@ -168,7 +163,7 @@ export default {
const customError = this.encapsulateError('nameRegex', errorMessage);
throw customError;
} else {
- this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE);
+ this.navigateToSettingsWithSuccessAlert();
}
})
.catch((error) => {
@@ -183,12 +178,17 @@ export default {
this.$emit('input', { ...this.value, [model]: newValue });
this.apiErrors[model] = undefined;
},
+ navigateToSettingsWithSuccessAlert() {
+ const alertQuery = objectToQuery({ [SHOW_SETUP_SUCCESS_ALERT]: true });
+
+ visitUrl(`${this.projectSettingsPath}?${alertQuery}`);
+ },
},
};
</script>
<template>
- <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset">
+ <form @submit.prevent="submit">
<expiration-toggle
:value="prefilledForm.enabled"
:disabled="showLoadingIcon"
@@ -199,7 +199,7 @@ export default {
<div class="gl-display-flex gl-mt-7">
<expiration-dropdown
- v-model="prefilledForm.cadence"
+ :value="prefilledForm.cadence"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.cadence"
:label="$options.i18n.CADENCE_LABEL"
@@ -231,7 +231,7 @@ export default {
</gl-sprintf>
</p>
<expiration-dropdown
- v-model="prefilledForm.keepN"
+ :value="prefilledForm.keepN"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.keepN"
:label="$options.i18n.KEEP_N_LABEL"
@@ -270,7 +270,7 @@ export default {
</gl-sprintf>
</p>
<expiration-dropdown
- v-model="prefilledForm.olderThan"
+ :value="prefilledForm.olderThan"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.olderThan"
:label="$options.i18n.EXPIRATION_SCHEDULE_LABEL"
@@ -306,7 +306,7 @@ export default {
</gl-button>
<gl-button
data-testid="cancel-button"
- type="reset"
+ :href="projectSettingsPath"
:disabled="isCancelButtonDisabled"
class="gl-mr-4"
>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 710cfe7b1eb..2c1368262f2 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -1,18 +1,57 @@
<script>
+import { GlAlert } from '@gitlab/ui';
+import { historyReplaceState } from '~/lib/utils/common_utils';
+import { getParameterByName } from '~/lib/utils/url_utility';
+import {
+ SHOW_SETUP_SUCCESS_ALERT,
+ UPDATE_SETTINGS_SUCCESS_MESSAGE,
+} from '~/packages_and_registries/settings/project/constants';
import ContainerExpirationPolicy from './container_expiration_policy.vue';
import PackagesCleanupPolicy from './packages_cleanup_policy.vue';
export default {
components: {
ContainerExpirationPolicy,
+ GlAlert,
PackagesCleanupPolicy,
},
inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'],
+ i18n: {
+ UPDATE_SETTINGS_SUCCESS_MESSAGE,
+ },
+ data() {
+ return {
+ showAlert: false,
+ };
+ },
+ mounted() {
+ this.checkAlert();
+ },
+ methods: {
+ checkAlert() {
+ const showAlert = getParameterByName(SHOW_SETUP_SUCCESS_ALERT);
+
+ if (showAlert) {
+ this.showAlert = true;
+ const cleanUrl = window.location.href.split('?')[0];
+ historyReplaceState(cleanUrl);
+ }
+ },
+ },
};
</script>
<template>
<div>
+ <gl-alert
+ v-if="showAlert"
+ variant="success"
+ class="gl-mt-5"
+ dismissible
+ @dismiss="showAlert = false"
+ >
+ {{ $options.i18n.UPDATE_SETTINGS_SUCCESS_MESSAGE }}
+ </gl-alert>
<packages-cleanup-policy v-if="showPackageRegistrySettings" />
<container-expiration-policy v-if="showContainerRegistrySettings" />
</div>
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 fcb4a8ee297..a9b47cbd343 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -4,6 +4,13 @@ export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up im
export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__(
`ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`,
);
+export const CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION = s__(
+ 'ContainerRegistry|Set rules to automatically remove unused packages to save storage space.',
+);
+export const CONTAINER_CLEANUP_POLICY_EDIT_RULES = s__('ContainerRegistry|Edit cleanup rules');
+export const CONTAINER_CLEANUP_POLICY_SET_RULES = s__('ContainerRegistry|Set cleanup rules');
+export const SHOW_SETUP_SUCCESS_ALERT = 'showSetupSuccessAlert';
+
export const SET_CLEANUP_POLICY_BUTTON = __('Save changes');
export const UNAVAILABLE_FEATURE_TITLE = s__(
`ContainerRegistry|Cleanup policy for tags is disabled`,
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
index daf1da6eac8..57c8d07e620 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
@@ -18,6 +18,7 @@ export default () => {
enableHistoricEntries,
projectPath,
adminSettingsPath,
+ cleanupSettingsPath,
tagsRegexHelpPagePath,
helpPagePath,
showContainerRegistrySettings,
@@ -34,6 +35,7 @@ export default () => {
enableHistoricEntries: parseBoolean(enableHistoricEntries),
projectPath,
adminSettingsPath,
+ cleanupSettingsPath,
tagsRegexHelpPagePath,
helpPagePath,
showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings),
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js
new file mode 100644
index 00000000000..b1401c448a1
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js
@@ -0,0 +1,41 @@
+import { GlToast } from '@gitlab/ui';
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import Translate from '~/vue_shared/translate';
+import CleanupImageTags from './components/cleanup_image_tags.vue';
+import { apolloProvider } from './graphql/index';
+
+Vue.use(GlToast);
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-registry-settings-cleanup-image-tags');
+ if (!el) {
+ return null;
+ }
+ const {
+ isAdmin,
+ enableHistoricEntries,
+ projectPath,
+ adminSettingsPath,
+ projectSettingsPath,
+ tagsRegexHelpPagePath,
+ helpPagePath,
+ } = el.dataset;
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ isAdmin: parseBoolean(isAdmin),
+ enableHistoricEntries: parseBoolean(enableHistoricEntries),
+ projectPath,
+ adminSettingsPath,
+ projectSettingsPath,
+ tagsRegexHelpPagePath,
+ helpPagePath,
+ },
+ render(createElement) {
+ return createElement(CleanupImageTags, {});
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue
index b2b1d2c8212..363304c20ce 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue
@@ -18,6 +18,11 @@ export default {
type: String,
required: true,
},
+ tokens: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
@@ -68,7 +73,7 @@ export default {
v-if="mountRegistrySearch"
:filters="filters"
:sorting="sorting"
- :tokens="$options.tokens"
+ :tokens="tokens"
:sortable-fields="sortableFields"
@sorting:changed="updateSortingAndEmitUpdate"
@filter:changed="updateFilters"
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
index 0458b914b58..7740924b058 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
@@ -1,7 +1,7 @@
<template>
<section class="settings gl-py-7">
- <div class="gl-lg-display-flex gl-gap-6">
- <div class="gl-lg-w-40p gl-pr-10 gl-flex-shrink-0">
+ <div class="row">
+ <div class="col-lg-4">
<h4>
<slot name="title"></slot>
</h4>
@@ -9,7 +9,7 @@
<slot name="description"></slot>
</p>
</div>
- <div class="gl-pt-3 gl-flex-grow-1">
+ <div class="col-lg-8 gl-pt-3">
<slot></slot>
</div>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
index 6744e821565..7fd440d0b27 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
@@ -33,10 +33,10 @@ export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
);
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
- 'PackageRegistry|Something went wrong while deleting the package file.',
+ 'PackageRegistry|Something went wrong while deleting the package asset.',
);
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
- 'PackageRegistry|Package file deleted successfully',
+ 'PackageRegistry|Package asset deleted successfully',
);
export const PACKAGE_ERROR_STATUS = 'error';
diff --git a/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js b/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js
new file mode 100644
index 00000000000..9b6fba9876e
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js
@@ -0,0 +1,3 @@
+import initRunnerTokenExpirationIntervals from '~/admin/application_settings/runner_token_expiration/index';
+
+initRunnerTokenExpirationIntervals();
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
index 84027203783..616005565c4 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
@@ -63,6 +63,8 @@ export default class PayloadPreviewer {
insertPayload(data) {
this.isInserted = true;
+
+ // eslint-disable-next-line no-unsanitized/property
this.getContainer().innerHTML = data;
this.showPayload();
}
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
index a4d89889d57..4cd312b403c 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/index.js
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
-import stopJobsModal from './components/stop_jobs_modal.vue';
+import StopJobsModal from './components/stop_jobs_modal.vue';
Vue.use(Translate);
@@ -14,7 +14,7 @@ function initJobs() {
new Vue({
el: `#js-${modalId}`,
components: {
- stopJobsModal,
+ StopJobsModal,
},
mounted() {
stopJobsButton.classList.remove('disabled');
diff --git a/app/assets/javascripts/pages/admin/runners/edit/index.js b/app/assets/javascripts/pages/admin/runners/edit/index.js
index ddf135a2732..03d31f49a99 100644
--- a/app/assets/javascripts/pages/admin/runners/edit/index.js
+++ b/app/assets/javascripts/pages/admin/runners/edit/index.js
@@ -1,3 +1,3 @@
-import { initAdminRunnerEdit } from '~/runner/admin_runner_edit';
+import { initRunnerEdit } from '~/runner/runner_edit';
-initAdminRunnerEdit();
+initRunnerEdit('#js-admin-runner-edit');
diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js
index f5e6d044865..b2cbd52fb27 100644
--- a/app/assets/javascripts/pages/admin/topics/edit/index.js
+++ b/app/assets/javascripts/pages/admin/topics/edit/index.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import GLForm from '~/gl_form';
import initFilePickers from '~/file_pickers';
import ZenMode from '~/zen_mode';
-import initRemoveAvatar from '~/admin/topics';
+import { initRemoveAvatar } from '~/admin/topics';
new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
initFilePickers();
diff --git a/app/assets/javascripts/pages/admin/topics/index.js b/app/assets/javascripts/pages/admin/topics/index.js
new file mode 100644
index 00000000000..ec0e11660d2
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/topics/index.js
@@ -0,0 +1,3 @@
+import { initMergeTopics } from '~/admin/topics';
+
+initMergeTopics();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index e45a40bd44c..b6f42a27002 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -140,10 +140,10 @@ export default class Todos {
restoreBtn.classList.add('hidden');
doneBtn.classList.remove('hidden');
} else if (target === doneBtn) {
- row.classList.add('done-reversible');
+ row.classList.add('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100');
restoreBtn.classList.remove('hidden');
} else if (target === restoreBtn) {
- row.classList.remove('done-reversible');
+ row.classList.remove('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100');
doneBtn.classList.remove('hidden');
} else {
row.parentNode.removeChild(row);
@@ -200,9 +200,11 @@ export default class Todos {
});
document.dispatchEvent(event);
+ // eslint-disable-next-line no-unsanitized/property
document.querySelector('.js-todos-pending .js-todos-badge').innerHTML = addDelimiter(
data.count,
);
+ // eslint-disable-next-line no-unsanitized/property
document.querySelector('.js-todos-done .js-todos-badge').innerHTML = addDelimiter(
data.done_count,
);
diff --git a/app/assets/javascripts/pages/groups/details/index.js b/app/assets/javascripts/pages/groups/details/index.js
index 0417134f2a7..92490368b15 100644
--- a/app/assets/javascripts/pages/groups/details/index.js
+++ b/app/assets/javascripts/pages/groups/details/index.js
@@ -1,3 +1,5 @@
+import { initGroupOverviewTabs } from '~/groups/init_overview_tabs';
import initGroupDetails from '../shared/group_details';
initGroupDetails('details');
+initGroupOverviewTabs();
diff --git a/app/assets/javascripts/pages/groups/runners/edit/index.js b/app/assets/javascripts/pages/groups/runners/edit/index.js
new file mode 100644
index 00000000000..febb0026b67
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/runners/edit/index.js
@@ -0,0 +1,3 @@
+import { initRunnerEdit } from '~/runner/runner_edit';
+
+initRunnerEdit('#js-group-runner-edit');
diff --git a/app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js
new file mode 100644
index 00000000000..1943704ac3d
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js
@@ -0,0 +1,6 @@
+// This "page" is only rendered as response to the create_deploy_token form.
+// It shows the secret token to the user one time, but is otherwise identical
+// with the Settings/Repository page.
+//
+// This is why we just import the other page's JavaScript here.
+import '../show/index';
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index e4a84dd5eec..161fca83a58 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,5 +1,7 @@
import leaveByUrl from '~/namespaces/leave_by_url';
+import { initGroupOverviewTabs } from '~/groups/init_overview_tabs';
import initGroupDetails from '../shared/group_details';
leaveByUrl('group');
initGroupDetails();
+initGroupOverviewTabs();
diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js
index 6b12604c76b..28b1aa02dfa 100644
--- a/app/assets/javascripts/pages/profiles/keys/index.js
+++ b/app/assets/javascripts/pages/profiles/keys/index.js
@@ -1,5 +1,6 @@
import initConfirmModal from '~/confirm_modal';
import AddSshKeyValidation from '~/profile/add_ssh_key_validation';
+import { initExpiresAtField } from '~/access_tokens/index';
initConfirmModal();
@@ -23,3 +24,5 @@ function initSshKeyValidation() {
}
initSshKeyValidation();
+
+initExpiresAtField();
diff --git a/app/assets/javascripts/pages/profiles/show/emoji_menu.js b/app/assets/javascripts/pages/profiles/show/emoji_menu.js
deleted file mode 100644
index 286c1f1e929..00000000000
--- a/app/assets/javascripts/pages/profiles/show/emoji_menu.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import '~/commons/bootstrap';
-import { AwardsHandler } from '~/awards_handler';
-
-class EmojiMenu extends AwardsHandler {
- constructor(emoji, toggleButtonSelector, menuClass, selectEmojiCallback) {
- super(emoji);
-
- this.selectEmojiCallback = selectEmojiCallback;
- this.toggleButtonSelector = toggleButtonSelector;
- this.menuClass = menuClass;
- }
-
- postEmoji($emojiButton, awardUrl, selectedEmoji, callback) {
- this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji));
- callback();
- }
-}
-
-export default EmojiMenu;
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index 226ef4c4e23..96ea7329e6e 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -1,88 +1,18 @@
import emojiRegex from 'emoji-regex';
-import $ from 'jquery';
-import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
-import * as Emoji from '~/emoji';
-import createFlash from '~/flash';
import { __ } from '~/locale';
-import EmojiMenu from './emoji_menu';
+import { initSetStatusForm } from '~/profile/profile';
-const defaultStatusEmoji = 'speech_balloon';
-const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu';
-const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector);
-const statusEmojiField = document.getElementById('js-status-emoji-field');
-const statusMessageField = document.getElementById('js-status-message-field');
-
-const toggleNoEmojiPlaceholder = (isVisible) => {
- const placeholderElement = document.getElementById('js-no-emoji-placeholder');
- placeholderElement.classList.toggle('hidden', !isVisible);
-};
-
-const findStatusEmoji = () => toggleEmojiMenuButton.querySelector('gl-emoji');
-const removeStatusEmoji = () => {
- const statusEmoji = findStatusEmoji();
- if (statusEmoji) {
- statusEmoji.remove();
- }
-};
-
-const selectEmojiCallback = (emoji, emojiTag) => {
- statusEmojiField.value = emoji;
- toggleNoEmojiPlaceholder(false);
- removeStatusEmoji();
- toggleEmojiMenuButton.innerHTML += emojiTag;
-};
-
-const clearEmojiButton = document.getElementById('js-clear-user-status-button');
-clearEmojiButton.addEventListener('click', () => {
- statusEmojiField.value = '';
- statusMessageField.value = '';
- removeStatusEmoji();
- toggleNoEmojiPlaceholder(true);
-});
-
-const emojiAutocomplete = new GfmAutoComplete();
-emojiAutocomplete.setup($(statusMessageField), { emojis: true });
+initSetStatusForm();
const userNameInput = document.getElementById('user_name');
-userNameInput.addEventListener('input', () => {
- const EMOJI_REGEX = emojiRegex();
- if (EMOJI_REGEX.test(userNameInput.value)) {
- // set field to invalid so it gets detected by GlFieldErrors
- userNameInput.setCustomValidity(__('Invalid field'));
- } else {
- userNameInput.setCustomValidity('');
- }
-});
-
-Emoji.initEmojiMap()
- .then(() => {
- const emojiMenu = new EmojiMenu(
- Emoji,
- toggleEmojiMenuButtonSelector,
- 'js-status-emoji-menu',
- selectEmojiCallback,
- );
- emojiMenu.bindEvents();
-
- const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji);
- statusMessageField.addEventListener('input', () => {
- const hasStatusMessage = statusMessageField.value.trim() !== '';
- const statusEmoji = findStatusEmoji();
- if (hasStatusMessage && statusEmoji) {
- return;
- }
-
- if (hasStatusMessage) {
- toggleNoEmojiPlaceholder(false);
- toggleEmojiMenuButton.innerHTML += defaultEmojiTag;
- } else if (statusEmoji.dataset.name === defaultStatusEmoji) {
- toggleNoEmojiPlaceholder(true);
- removeStatusEmoji();
- }
- });
- })
- .catch(() =>
- createFlash({
- message: __('Failed to load emoji list.'),
- }),
- );
+if (userNameInput) {
+ userNameInput.addEventListener('input', () => {
+ const EMOJI_REGEX = emojiRegex();
+ if (EMOJI_REGEX.test(userNameInput.value)) {
+ // set field to invalid so it gets detected by GlFieldErrors
+ userNameInput.setCustomValidity(__('Invalid field'));
+ } else {
+ userNameInput.setCustomValidity('');
+ }
+ });
+}
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index 49fdf5bb6b5..96c4d0e0670 100644
--- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -8,7 +8,10 @@ const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSk
if (skippable) {
const button = `<br/><a class="btn gl-button btn-sm btn-confirm gl-mt-3" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`;
const flashAlert = document.querySelector('.flash-alert');
- if (flashAlert) flashAlert.insertAdjacentHTML('beforeend', button);
+ if (flashAlert) {
+ // eslint-disable-next-line no-unsanitized/method
+ flashAlert.insertAdjacentHTML('beforeend', button);
+ }
}
mount2faRegistration();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 740fdb8a96a..e45f9a10294 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -9,7 +9,7 @@ import GpgBadges from '~/gpg_badges';
import createDefaultClient from '~/lib/graphql';
import initBlob from '~/pages/projects/init_blob';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
-import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import 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';
@@ -64,7 +64,7 @@ if (statusLink) {
new Vue({
el: CommitPipelineStatusEl,
components: {
- commitPipelineStatus,
+ CommitPipelineStatus,
},
render(createElement) {
return createElement('commit-pipeline-status', {
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 f92a40e057f..b415e36bf09 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
@@ -3,15 +3,12 @@ import {
GlIcon,
GlLink,
GlForm,
- GlFormInputGroup,
- GlInputGroupText,
GlFormInput,
GlFormGroup,
GlFormTextarea,
GlButton,
GlFormRadio,
GlFormRadioGroup,
- GlFormSelect,
} from '@gitlab/ui';
import { kebabCase } from 'lodash';
import { buildApiUrl } from '~/api/api_utils';
@@ -21,16 +18,13 @@ import csrf from '~/lib/utils/csrf';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
-
-const PRIVATE_VISIBILITY = 'private';
-const INTERNAL_VISIBILITY = 'internal';
-const PUBLIC_VISIBILITY = 'public';
-
-const VISIBILITY_LEVEL = {
- [PRIVATE_VISIBILITY]: 0,
- [INTERNAL_VISIBILITY]: 10,
- [PUBLIC_VISIBILITY]: 20,
-};
+import {
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ VISIBILITY_LEVEL_INTERNAL_STRING,
+ VISIBILITY_LEVEL_PUBLIC_STRING,
+ VISIBILITY_LEVELS_STRING_TO_INTEGER,
+} from '~/visibility_level/constants';
+import ProjectNamespace from './project_namespace.vue';
const initFormField = ({ value, required = true, skipValidation = false }) => ({
value,
@@ -39,28 +33,18 @@ const initFormField = ({ value, required = true, skipValidation = false }) => ({
feedback: null,
});
-function sortNamespaces(namespaces) {
- if (!namespaces || !namespaces?.length) {
- return namespaces;
- }
-
- return namespaces.sort((a, b) => a.full_name.localeCompare(b.full_name));
-}
-
export default {
components: {
GlForm,
GlIcon,
GlLink,
GlButton,
- GlFormInputGroup,
- GlInputGroupText,
GlFormInput,
GlFormTextarea,
GlFormGroup,
GlFormRadio,
GlFormRadioGroup,
- GlFormSelect,
+ ProjectNamespace,
},
directives: {
validation: validation(),
@@ -72,9 +56,6 @@ export default {
visibilityHelpPath: {
default: '',
},
- endpoint: {
- default: '',
- },
projectFullPath: {
default: '',
},
@@ -96,6 +77,9 @@ export default {
restrictedVisibilityLevels: {
default: [],
},
+ namespaceId: {
+ default: '',
+ },
},
data() {
const form = {
@@ -117,20 +101,17 @@ export default {
};
return {
isSaving: false,
- namespaces: [],
form,
};
},
computed: {
- projectUrl() {
- return `${gon.gitlab_url}/`;
- },
projectVisibilityLevel() {
- return VISIBILITY_LEVEL[this.projectVisibility];
+ return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility];
},
namespaceVisibilityLevel() {
- const visibility = this.form.fields.namespace.value?.visibility || PUBLIC_VISIBILITY;
- return VISIBILITY_LEVEL[visibility];
+ const visibility =
+ this.form.fields.namespace.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING;
+ return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility];
},
visibilityLevelCap() {
return Math.min(this.projectVisibilityLevel, this.namespaceVisibilityLevel);
@@ -139,7 +120,7 @@ export default {
return new Set(this.restrictedVisibilityLevels);
},
allowedVisibilityLevels() {
- const allowedLevels = Object.entries(VISIBILITY_LEVEL).reduce(
+ const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce(
(levels, [levelName, levelValue]) => {
if (
!this.restrictedVisibilityLevelsSet.has(levelValue) &&
@@ -153,7 +134,7 @@ export default {
);
if (!allowedLevels.length) {
- return [PRIVATE_VISIBILITY];
+ return [VISIBILITY_LEVEL_PRIVATE_STRING];
}
return allowedLevels;
@@ -162,58 +143,56 @@ export default {
return [
{
text: s__('ForkProject|Private'),
- value: PRIVATE_VISIBILITY,
+ value: VISIBILITY_LEVEL_PRIVATE_STRING,
icon: 'lock',
help: s__(
'ForkProject|Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
),
- disabled: this.isVisibilityLevelDisabled(PRIVATE_VISIBILITY),
+ disabled: this.isVisibilityLevelDisabled(VISIBILITY_LEVEL_PRIVATE_STRING),
},
{
text: s__('ForkProject|Internal'),
- value: INTERNAL_VISIBILITY,
+ value: VISIBILITY_LEVEL_INTERNAL_STRING,
icon: 'shield',
help: s__('ForkProject|The project can be accessed by any logged in user.'),
- disabled: this.isVisibilityLevelDisabled(INTERNAL_VISIBILITY),
+ disabled: this.isVisibilityLevelDisabled(VISIBILITY_LEVEL_INTERNAL_STRING),
},
{
text: s__('ForkProject|Public'),
- value: PUBLIC_VISIBILITY,
+ value: VISIBILITY_LEVEL_PUBLIC_STRING,
icon: 'earth',
help: s__('ForkProject|The project can be accessed without any authentication.'),
- disabled: this.isVisibilityLevelDisabled(PUBLIC_VISIBILITY),
+ disabled: this.isVisibilityLevelDisabled(VISIBILITY_LEVEL_PUBLIC_STRING),
},
];
},
},
watch: {
// eslint-disable-next-line func-names
- 'form.fields.namespace.value': function () {
- this.form.fields.visibility.value =
- this.restrictedVisibilityLevels.length !== 0 ? null : PRIVATE_VISIBILITY;
- },
- // eslint-disable-next-line func-names
'form.fields.name.value': function (newVal) {
this.form.fields.slug.value = kebabCase(newVal);
},
},
- mounted() {
- this.fetchNamespaces();
- },
methods: {
- async fetchNamespaces() {
- const { data } = await axios.get(this.endpoint);
- this.namespaces = sortNamespaces(data.namespaces);
- },
isVisibilityLevelDisabled(visibility) {
return !this.allowedVisibilityLevels.includes(visibility);
},
getInitialVisibilityValue() {
return this.restrictedVisibilityLevels.length !== 0 ? null : this.projectVisibility;
},
+ setNamespace(namespace) {
+ this.form.fields.visibility.value =
+ this.restrictedVisibilityLevels.length !== 0 ? null : VISIBILITY_LEVEL_PRIVATE_STRING;
+ this.form.fields.namespace.value = namespace;
+ this.form.fields.namespace.state = true;
+ },
async onSubmit() {
this.form.showValidation = true;
+ if (!this.form.fields.namespace.value) {
+ this.form.fields.namespace.state = false;
+ }
+
if (!this.form.state) {
return;
}
@@ -282,30 +261,7 @@ export default {
:state="form.fields.namespace.state"
:invalid-feedback="s__('ForkProject|Please select a namespace')"
>
- <gl-form-input-group>
- <template #prepend>
- <gl-input-group-text>
- {{ projectUrl }}
- </gl-input-group-text>
- </template>
- <gl-form-select
- id="fork-url"
- v-model="form.fields.namespace.value"
- v-validation:[form.showValidation]
- name="namespace"
- data-testid="fork-url-input"
- data-qa-selector="fork_namespace_dropdown"
- :state="form.fields.namespace.state"
- required
- >
- <template #first>
- <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option>
- </template>
- <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace">
- {{ namespace.full_name }}
- </option>
- </gl-form-select>
- </gl-form-input-group>
+ <project-namespace @select="setNamespace" />
</gl-form-group>
</div>
<div class="gl-flex-basis-half">
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
new file mode 100644
index 00000000000..2b3055ecd66
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
@@ -0,0 +1,136 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ GlTruncate,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
+import { s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import searchForkableNamespaces from '../queries/search_forkable_namespaces.query.graphql';
+
+export default {
+ components: {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ GlTruncate,
+ },
+ apollo: {
+ project: {
+ query: searchForkableNamespaces,
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ search: this.search,
+ };
+ },
+ skip() {
+ const { length } = this.search;
+ return length > 0 && length < MINIMUM_SEARCH_LENGTH;
+ },
+ error(error) {
+ createFlash({
+ message: s__(
+ 'ForkProject|Something went wrong while loading data. Please refresh the page to try again.',
+ ),
+ captureError: true,
+ error,
+ });
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
+ },
+ inject: ['projectFullPath'],
+ data() {
+ return {
+ project: {},
+ search: '',
+ selectedNamespace: null,
+ };
+ },
+ computed: {
+ rootUrl() {
+ return `${gon.gitlab_url}/`;
+ },
+ namespaces() {
+ return this.project.forkTargets?.nodes || [];
+ },
+ hasMatches() {
+ return this.namespaces.length;
+ },
+ dropdownText() {
+ return this.selectedNamespace?.fullPath || s__('ForkProject|Select a namespace');
+ },
+ },
+ methods: {
+ handleDropdownShown() {
+ this.$refs.search.focusInput();
+ },
+ setNamespace(namespace) {
+ const id = getIdFromGraphQLId(namespace.id);
+
+ this.$emit('select', {
+ id,
+ name: namespace.name,
+ visibility: namespace.visibility,
+ });
+
+ this.selectedNamespace = { id, fullPath: namespace.fullPath };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button-group class="gl-w-full">
+ <gl-button class="gl-text-truncate gl-flex-grow-0! gl-max-w-34" label :title="rootUrl">{{
+ rootUrl
+ }}</gl-button>
+
+ <gl-dropdown
+ class="gl-flex-grow-1"
+ toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
+ data-qa-selector="select_namespace_dropdown"
+ data-testid="select_namespace_dropdown"
+ no-flip
+ @shown="handleDropdownShown"
+ >
+ <template #button-text>
+ <gl-truncate :text="dropdownText" position="start" with-tooltip />
+ </template>
+ <gl-search-box-by-type
+ ref="search"
+ v-model.trim="search"
+ :is-loading="$apollo.queries.project.loading"
+ data-qa-selector="select_namespace_dropdown_search_field"
+ data-testid="select_namespace_dropdown_search_field"
+ />
+ <template v-if="!$apollo.queries.project.loading">
+ <template v-if="hasMatches">
+ <gl-dropdown-section-header>{{ __('Namespaces') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="namespace of namespaces"
+ :key="namespace.id"
+ data-qa-selector="select_namespace_dropdown_item"
+ @click="setNamespace(namespace)"
+ >
+ {{ namespace.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text>
+ </template>
+ </gl-dropdown>
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js
index cbf74f755e7..d3a5ce5390f 100644
--- a/app/assets/javascripts/pages/projects/forks/new/index.js
+++ b/app/assets/javascripts/pages/projects/forks/new/index.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import App from './components/app.vue';
const mountElement = document.getElementById('fork-groups-mount-element');
@@ -17,9 +19,14 @@ const {
restrictedVisibilityLevels,
} = mountElement.dataset;
+Vue.use(VueApollo);
+
// eslint-disable-next-line no-new
new Vue({
el: mountElement,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient(),
+ }),
provide: {
newGroupPath,
visibilityHelpPath,
diff --git a/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql b/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql
new file mode 100644
index 00000000000..089b57815bd
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql
@@ -0,0 +1,13 @@
+query searchForkableNamespaces($projectPath: ID!, $search: String) {
+ project(fullPath: $projectPath) {
+ id
+ forkTargets(search: $search) {
+ nodes {
+ id
+ fullPath
+ name
+ visibility
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js
deleted file mode 100644
index 5482324f1cd..00000000000
--- a/app/assets/javascripts/pages/projects/google_cloud/databases/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import init from '~/google_cloud/databases/index';
-
-init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js
new file mode 100644
index 00000000000..e1dc0116707
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/databases/init_index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js
new file mode 100644
index 00000000000..698e788789b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/databases/init_new';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
index d7e68484143..08d24344ffc 100644
--- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -180,7 +180,7 @@ export default {
v-for="({ group_name }, index) in dailyCoverageData"
:key="index"
:value="group_name"
- :is-check-item="true"
+ is-check-item
:is-checked="index === selectedCoverageIndex"
@click="setSelectedCoverage(index)"
>
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
index ec21d8c84e0..5179d1b31ab 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -1,5 +1,74 @@
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+
+import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown';
+
import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
import initCheckFormState from './check_form_state';
+import initFormUpdate from './update_form';
+
+function initTargetBranchSelector() {
+ const targetBranch = document.querySelector('.js-target-branch');
+ const { selected, fieldName, refsUrl } = targetBranch?.dataset ?? {};
+ const formField = document.querySelector(`input[name="${fieldName}"]`);
+
+ if (targetBranch && refsUrl && formField) {
+ /* eslint-disable-next-line no-new */
+ new GitLabDropdown(targetBranch, {
+ selectable: true,
+ filterable: true,
+ filterRemote: Boolean(refsUrl),
+ filterInput: 'input[type="search"]',
+ data(term, callback) {
+ const params = {
+ search: term,
+ };
+
+ axios
+ .get(refsUrl, {
+ params,
+ })
+ .then(({ data }) => {
+ callback(data);
+ })
+ .catch(() =>
+ createFlash({
+ message: __('Error fetching branches'),
+ }),
+ );
+ },
+ renderRow(branch) {
+ const item = document.createElement('li');
+ const link = document.createElement('a');
+
+ link.setAttribute('href', '#');
+ link.dataset.branch = branch;
+ link.classList.toggle('is-active', branch === selected);
+ link.textContent = branch;
+
+ item.appendChild(link);
+
+ return item;
+ },
+ id(obj, $el) {
+ return $el.data('id');
+ },
+ toggleLabel(obj, $el) {
+ return $el.text().trim();
+ },
+ clicked({ $el, e }) {
+ e.preventDefault();
+
+ const branchName = $el[0].dataset.branch;
+
+ formField.setAttribute('value', branchName);
+ },
+ });
+ }
+}
initMergeRequest();
+initFormUpdate();
initCheckFormState();
+initTargetBranchSelector();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js b/app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js
new file mode 100644
index 00000000000..3bb64f741e7
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js
@@ -0,0 +1,23 @@
+const findForm = () => document.querySelector('.merge-request-form');
+
+const removeHiddenCheckbox = (node) => {
+ const checkboxWrapper = node.closest('.form-check');
+ const hiddenCheckbox = checkboxWrapper.querySelector('input[type="hidden"]');
+ hiddenCheckbox.remove();
+};
+
+export default () => {
+ const updateCheckboxes = () => {
+ const checkboxes = document.querySelectorAll('.js-form-update');
+
+ if (!checkboxes.length) return;
+
+ checkboxes.forEach((checkbox) => {
+ if (checkbox.checked) {
+ removeHiddenCheckbox(checkbox);
+ }
+ });
+ };
+
+ findForm().addEventListener('submit', () => updateCheckboxes());
+};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index 2db804e1ad8..30734f0b698 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { s__ } from '~/locale';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { initPipelineCountListener } from '~/commit/pipelines/utils';
import { initIssuableSidebar } from '~/issuable';
@@ -10,6 +11,7 @@ import ZenMode from '~/zen_mode';
import initAwardsApp from '~/emoji/awards_app';
import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue';
import { initMrExperienceSurvey } from '~/surveys/merge_request_experience';
+import toast from '~/vue_shared/plugins/global_toast';
import getStateQuery from './queries/get_state.query.graphql';
export default function initMergeRequestShow() {
@@ -65,4 +67,10 @@ export default function initMergeRequestShow() {
});
},
});
+
+ const copyReferenceButton = document.querySelector('.js-copy-reference');
+
+ copyReferenceButton?.addEventListener('click', () => {
+ toast(s__('MergeRequests|Reference copied'));
+ });
}
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index 7f49eb60c5c..cc5c393ff8c 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,9 +1,14 @@
+import Vue from 'vue';
+import StickyHeader from '~/merge_requests/components/sticky_header.vue';
import { initReviewBar } from '~/batch_comments';
import { initIssuableHeaderWarnings } from '~/issuable';
import initMrNotes from '~/mr_notes';
import store from '~/mr_notes/stores';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
+import { parseBoolean } from '~/lib/utils/common_utils';
import initShow from '../init_merge_request_show';
+import getStateQuery from '../queries/get_state.query.graphql';
initMrNotes();
initShow();
@@ -12,4 +17,29 @@ requestIdleCallback(() => {
initSidebarBundle(store);
initReviewBar();
initIssuableHeaderWarnings(store);
+
+ const el = document.getElementById('js-merge-sticky-header');
+
+ if (el) {
+ const { data } = el.dataset;
+ const { iid, projectPath, title, tabs, isFluidLayout } = JSON.parse(data);
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ store,
+ apolloProvider,
+ provide: {
+ query: getStateQuery,
+ iid,
+ projectPath,
+ title,
+ tabs,
+ isFluidLayout: parseBoolean(isFluidLayout),
+ },
+ render(h) {
+ return h(StickyHeader);
+ },
+ });
+ }
});
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
index 37e8a316ee4..b3ad50f395b 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -29,7 +29,7 @@ export default {
</script>
<template>
<div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout">
- <div class="bordered-box landing content-block" data-testid="innerContent">
+ <div class="bordered-box landing content-block gl-p-5!" data-testid="innerContent">
<gl-button
category="tertiary"
icon="close"
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 5dae812bbcb..eae721771de 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
@@ -6,7 +6,7 @@ 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 IntervalPatternInput from './components/interval_pattern_input.vue';
import TimezoneDropdown from './components/timezone_dropdown';
Vue.use(Translate);
@@ -19,7 +19,7 @@ function initIntervalPatternInput() {
return new Vue({
el: intervalPatternMount,
components: {
- intervalPatternInput,
+ IntervalPatternInput,
},
render(createElement) {
return createElement('interval-pattern-input', {
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 032e2410233..ccabaad5b2e 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -141,7 +141,7 @@ export default class Project {
if (doesPathContainRef) {
// We are ignoring the url containing the ref portion
// and plucking the thereafter portion to reconstructure the url that is correct
- const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0];
+ const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0];
selectedUrl.searchParams.set('path', targetPath);
selectedUrl.hash = window.location.hash;
}
diff --git a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js
new file mode 100644
index 00000000000..739e666644c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js
@@ -0,0 +1,10 @@
+import groupsSelect from '~/groups_select';
+import UserCallout from '~/user_callout';
+import UsersSelect from '~/users_select';
+
+// eslint-disable-next-line no-new
+new UsersSelect();
+groupsSelect();
+
+// eslint-disable-next-line no-new
+new UserCallout({ className: 'js-mr-approval-callout' });
diff --git a/app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js b/app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js
new file mode 100644
index 00000000000..acd5d3febff
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js
@@ -0,0 +1,5 @@
+import registrySettingsCleanupTagsApp from '~/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle';
+import initSettingsPanels from '~/settings_panels';
+
+registrySettingsCleanupTagsApp();
+initSettingsPanels();
diff --git a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
index 1dc238b56b4..6a7c6028c95 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
@@ -1,3 +1 @@
-import initForm from '../form';
-
-initForm();
+import '../show/index';
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index c7c331c7de5..a82f485bf44 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -5,7 +5,11 @@ import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/s
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__ } from '~/locale';
import {
- visibilityOptions,
+ VISIBILITY_LEVEL_PRIVATE_INTEGER,
+ VISIBILITY_LEVEL_INTERNAL_INTEGER,
+ VISIBILITY_LEVEL_PUBLIC_INTEGER,
+} from '~/visibility_level/constants';
+import {
visibilityLevelDescriptions,
featureAccessLevelMembers,
featureAccessLevelEveryone,
@@ -14,8 +18,8 @@ import {
featureAccessLevelDescriptions,
} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
-import projectFeatureSetting from './project_feature_setting.vue';
-import projectSettingRow from './project_setting_row.vue';
+import ProjectFeatureSetting from './project_feature_setting.vue';
+import ProjectSettingRow from './project_setting_row.vue';
const FEATURE_ACCESS_LEVEL_ANONYMOUS = [30, s__('ProjectSettings|Everyone')];
@@ -33,6 +37,11 @@ export default {
environmentsHelpText: s__(
'ProjectSettings|Every project can make deployments to environments either via CI/CD or API calls. Non-project members have read-only access.',
),
+ featureFlagsLabel: s__('ProjectSettings|Feature flags'),
+ featureFlagsHelpText: s__(
+ 'ProjectSettings|Roll out new features without redeploying with feature flags.',
+ ),
+ monitorLabel: s__('ProjectSettings|Monitor'),
packagesHelpText: s__(
'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.',
),
@@ -45,6 +54,10 @@ export default {
ciCdLabel: __('CI/CD'),
repositoryLabel: s__('ProjectSettings|Repository'),
requirementsLabel: s__('ProjectSettings|Requirements'),
+ releasesLabel: s__('ProjectSettings|Releases'),
+ releasesHelpText: s__(
+ 'ProjectSettings|Combine git tags with release notes, release evidence, and assets to create a release.',
+ ),
securityAndComplianceLabel: s__('ProjectSettings|Security & Compliance'),
snippetsLabel: s__('ProjectSettings|Snippets'),
wikiLabel: s__('ProjectSettings|Wiki'),
@@ -54,10 +67,13 @@ export default {
),
confirmButtonText: __('Save changes'),
},
+ VISIBILITY_LEVEL_PRIVATE_INTEGER,
+ VISIBILITY_LEVEL_INTERNAL_INTEGER,
+ VISIBILITY_LEVEL_PUBLIC_INTEGER,
components: {
- projectFeatureSetting,
- projectSettingRow,
+ ProjectFeatureSetting,
+ ProjectSettingRow,
GlButton,
GlIcon,
GlSprintf,
@@ -65,7 +81,7 @@ export default {
GlFormCheckbox,
GlToggle,
ConfirmDanger,
- otherProjectSettings: () =>
+ OtherProjectSettings: () =>
import(
'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue'
),
@@ -96,9 +112,9 @@ export default {
type: Array,
required: false,
default: () => [
- visibilityOptions.PRIVATE,
- visibilityOptions.INTERNAL,
- visibilityOptions.PUBLIC,
+ VISIBILITY_LEVEL_PRIVATE_INTEGER,
+ VISIBILITY_LEVEL_INTERNAL_INTEGER,
+ VISIBILITY_LEVEL_PUBLIC_INTEGER,
],
},
lfsAvailable: {
@@ -131,6 +147,21 @@ export default {
required: false,
default: '',
},
+ environmentsHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ featureFlagsHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ releasesHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
lfsHelpPath: {
type: String,
required: false,
@@ -197,8 +228,7 @@ export default {
},
data() {
const defaults = {
- visibilityOptions,
- visibilityLevel: visibilityOptions.PUBLIC,
+ visibilityLevel: VISIBILITY_LEVEL_PUBLIC_INTEGER,
issuesAccessLevel: featureAccessLevel.EVERYONE,
repositoryAccessLevel: featureAccessLevel.EVERYONE,
forkingAccessLevel: featureAccessLevel.EVERYONE,
@@ -214,6 +244,9 @@ export default {
securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
operationsAccessLevel: featureAccessLevel.EVERYONE,
environmentsAccessLevel: featureAccessLevel.EVERYONE,
+ featureFlagsAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
+ releasesAccessLevel: featureAccessLevel.EVERYONE,
+ monitorAccessLevel: featureAccessLevel.EVERYONE,
containerRegistryAccessLevel: featureAccessLevel.EVERYONE,
warnAboutPotentiallyUnwantedCharacters: true,
lfsEnabled: true,
@@ -234,7 +267,7 @@ export default {
computed: {
featureAccessLevelOptions() {
const options = [featureAccessLevelMembers];
- if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
+ if (this.visibilityLevel !== VISIBILITY_LEVEL_PRIVATE_INTEGER) {
options.push(featureAccessLevelEveryone);
}
return options;
@@ -246,18 +279,12 @@ export default {
);
},
- operationsFeatureAccessLevelOptions() {
- return this.featureAccessLevelOptions.filter(
- ([value]) => value <= this.operationsAccessLevel,
- );
- },
-
packageRegistryFeatureAccessLevelOptions() {
const options = [FEATURE_ACCESS_LEVEL_ANONYMOUS];
- if (this.visibilityLevel === visibilityOptions.PRIVATE) {
+ if (this.visibilityLevel === VISIBILITY_LEVEL_PRIVATE_INTEGER) {
options.unshift(featureAccessLevelMembers);
- } else if (this.visibilityLevel === visibilityOptions.INTERNAL) {
+ } else if (this.visibilityLevel === VISIBILITY_LEVEL_INTERNAL_INTEGER) {
options.unshift(featureAccessLevelEveryone);
}
@@ -268,15 +295,15 @@ export default {
const options = [featureAccessLevelMembers];
if (this.pagesAccessControlForced) {
- if (this.visibilityLevel === visibilityOptions.INTERNAL) {
+ if (this.visibilityLevel === VISIBILITY_LEVEL_INTERNAL_INTEGER) {
options.push(featureAccessLevelEveryone);
}
} else {
- if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
+ if (this.visibilityLevel !== VISIBILITY_LEVEL_PRIVATE_INTEGER) {
options.push(featureAccessLevelEveryone);
}
- if (this.visibilityLevel !== visibilityOptions.PUBLIC) {
+ if (this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER) {
options.push(FEATURE_ACCESS_LEVEL_ANONYMOUS);
}
}
@@ -290,6 +317,11 @@ export default {
environmentsEnabled() {
return this.environmentsAccessLevel > featureAccessLevel.NOT_ENABLED;
},
+
+ monitorEnabled() {
+ return this.monitorAccessLevel > featureAccessLevel.NOT_ENABLED;
+ },
+
repositoryEnabled() {
return this.repositoryAccessLevel > featureAccessLevel.NOT_ENABLED;
},
@@ -300,13 +332,13 @@ export default {
showContainerRegistryPublicNote() {
return (
- this.visibilityLevel === visibilityOptions.PUBLIC &&
+ this.visibilityLevel === VISIBILITY_LEVEL_PUBLIC_INTEGER &&
this.containerRegistryAccessLevel === featureAccessLevel.EVERYONE
);
},
repositoryHelpText() {
- if (this.visibilityLevel === visibilityOptions.PRIVATE) {
+ if (this.visibilityLevel === VISIBILITY_LEVEL_PRIVATE_INTEGER) {
return s__('ProjectSettings|View and edit files in this project.');
}
@@ -315,7 +347,7 @@ export default {
);
},
cveIdRequestIsDisabled() {
- return this.visibilityLevel !== visibilityOptions.PUBLIC;
+ return this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER;
},
isVisibilityReduced() {
return (
@@ -329,11 +361,19 @@ export default {
splitOperationsEnabled() {
return this.glFeatures.splitOperationsVisibilityPermissions;
},
+ monitorOperationsFeatureAccessLevelOptions() {
+ if (this.splitOperationsEnabled) {
+ return this.featureAccessLevelOptions.filter(([value]) => value <= this.monitorAccessLevel);
+ }
+ return this.featureAccessLevelOptions.filter(
+ ([value]) => value <= this.operationsAccessLevel,
+ );
+ },
},
watch: {
visibilityLevel(value, oldValue) {
- if (value === visibilityOptions.PRIVATE) {
+ if (value === VISIBILITY_LEVEL_PRIVATE_INTEGER) {
// when private, features are restricted to "only team members"
this.issuesAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
@@ -355,7 +395,7 @@ export default {
if (
this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE ||
(this.packageRegistryAccessLevel > featureAccessLevel.EVERYONE &&
- oldValue === visibilityOptions.PUBLIC)
+ oldValue === VISIBILITY_LEVEL_PUBLIC_INTEGER)
) {
this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS;
}
@@ -389,6 +429,18 @@ export default {
featureAccessLevel.PROJECT_MEMBERS,
this.environmentsAccessLevel,
);
+ this.featureFlagsAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.featureFlagsAccessLevel,
+ );
+ this.releasesAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.releasesAccessLevel,
+ );
+ this.monitorAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.monitorAccessLevel,
+ );
this.containerRegistryAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
this.containerRegistryAccessLevel,
@@ -398,7 +450,7 @@ export default {
this.pagesAccessLevel = featureAccessLevel.PROJECT_MEMBERS;
}
this.highlightChanges();
- } else if (oldValue === visibilityOptions.PRIVATE) {
+ } else if (oldValue === VISIBILITY_LEVEL_PRIVATE_INTEGER) {
// if changing away from private, make enabled features more permissive
if (this.issuesAccessLevel > featureAccessLevel.NOT_ENABLED)
this.issuesAccessLevel = featureAccessLevel.EVERYONE;
@@ -432,19 +484,21 @@ export default {
this.operationsAccessLevel = featureAccessLevel.EVERYONE;
if (this.environmentsAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.environmentsAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.monitorAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
+ this.monitorAccessLevel = featureAccessLevel.EVERYONE;
if (this.containerRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE;
this.highlightChanges();
} else if (this.packageRegistryAccessLevelEnabled) {
if (
- value === visibilityOptions.PUBLIC &&
+ value === VISIBILITY_LEVEL_PUBLIC_INTEGER &&
this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE
) {
// eslint-disable-next-line prefer-destructuring
this.packageRegistryAccessLevel = FEATURE_ACCESS_LEVEL_ANONYMOUS[0];
} else if (
- value === visibilityOptions.INTERNAL &&
+ value === VISIBILITY_LEVEL_INTERNAL_INTEGER &&
this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0]
) {
this.packageRegistryAccessLevel = featureAccessLevel.EVERYONE;
@@ -467,6 +521,16 @@ export default {
},
operationsAccessLevel(value, oldValue) {
+ this.updateSubFeatureAccessLevel(value, oldValue);
+ },
+
+ monitorAccessLevel(value, oldValue) {
+ this.updateSubFeatureAccessLevel(value, oldValue);
+ },
+ },
+
+ methods: {
+ updateSubFeatureAccessLevel(value, oldValue) {
if (value < oldValue) {
// sub-features cannot have more permissive access level
this.metricsDashboardAccessLevel = Math.min(this.metricsDashboardAccessLevel, value);
@@ -474,9 +538,7 @@ export default {
this.metricsDashboardAccessLevel = value;
}
},
- },
- methods: {
highlightChanges() {
this.highlightChangesClass = true;
this.$nextTick(() => {
@@ -514,20 +576,20 @@ export default {
data-qa-selector="project_visibility_dropdown"
>
<option
- :value="visibilityOptions.PRIVATE"
- :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
+ :value="$options.VISIBILITY_LEVEL_PRIVATE_INTEGER"
+ :disabled="!visibilityAllowed($options.VISIBILITY_LEVEL_PRIVATE_INTEGER)"
>
{{ s__('ProjectSettings|Private') }}
</option>
<option
- :value="visibilityOptions.INTERNAL"
- :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
+ :value="$options.VISIBILITY_LEVEL_INTERNAL_INTEGER"
+ :disabled="!visibilityAllowed($options.VISIBILITY_LEVEL_INTERNAL_INTEGER)"
>
{{ s__('ProjectSettings|Internal') }}
</option>
<option
- :value="visibilityOptions.PUBLIC"
- :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
+ :value="$options.VISIBILITY_LEVEL_PUBLIC_INTEGER"
+ :disabled="!visibilityAllowed($options.VISIBILITY_LEVEL_PUBLIC_INTEGER)"
>
{{ s__('ProjectSettings|Public') }}
</option>
@@ -558,7 +620,7 @@ export default {
<div class="gl-mt-4">
<strong class="gl-display-block">{{ s__('ProjectSettings|Additional options') }}</strong>
<label
- v-if="visibilityLevel !== visibilityOptions.PRIVATE"
+ v-if="visibilityLevel !== $options.VISIBILITY_LEVEL_PRIVATE_INTEGER"
class="gl-line-height-28 gl-font-weight-normal gl-mb-0"
>
<input
@@ -570,7 +632,7 @@ export default {
{{ s__('ProjectSettings|Users can request access') }}
</label>
<label
- v-if="visibilityLevel !== visibilityOptions.PUBLIC"
+ v-if="visibilityLevel !== $options.VISIBILITY_LEVEL_PUBLIC_INTEGER"
class="gl-line-height-28 gl-font-weight-normal gl-display-block gl-mb-0"
>
<input
@@ -847,6 +909,22 @@ export default {
/>
</project-setting-row>
<project-setting-row
+ v-if="splitOperationsEnabled"
+ ref="monitor-settings"
+ :label="$options.i18n.monitorLabel"
+ :help-text="
+ s__('ProjectSettings|Configure your project resources and monitor their health.')
+ "
+ >
+ <project-feature-setting
+ v-model="monitorAccessLevel"
+ :label="$options.i18n.monitorLabel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][monitor_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
+ v-else
ref="operations-settings"
:label="$options.i18n.operationsLabel"
:help-text="
@@ -869,7 +947,7 @@ export default {
<project-feature-setting
v-model="metricsDashboardAccessLevel"
:show-toggle="false"
- :options="operationsFeatureAccessLevelOptions"
+ :options="monitorOperationsFeatureAccessLevelOptions"
name="project[project_feature_attributes][metrics_dashboard_access_level]"
/>
</project-setting-row>
@@ -879,6 +957,7 @@ export default {
ref="environments-settings"
:label="$options.i18n.environmentsLabel"
:help-text="$options.i18n.environmentsHelpText"
+ :help-path="environmentsHelpPath"
>
<project-feature-setting
v-model="environmentsAccessLevel"
@@ -887,6 +966,32 @@ export default {
name="project[project_feature_attributes][environments_access_level]"
/>
</project-setting-row>
+ <project-setting-row
+ ref="feature-flags-settings"
+ :label="$options.i18n.featureFlagsLabel"
+ :help-text="$options.i18n.featureFlagsHelpText"
+ :help-path="featureFlagsHelpPath"
+ >
+ <project-feature-setting
+ v-model="featureFlagsAccessLevel"
+ :label="$options.i18n.featureFlagsLabel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][feature_flags_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
+ ref="releases-settings"
+ :label="$options.i18n.releasesLabel"
+ :help-text="$options.i18n.releasesHelpText"
+ :help-path="releasesHelpPath"
+ >
+ <project-feature-setting
+ v-model="releasesAccessLevel"
+ :label="$options.i18n.releasesLabel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][releases_access_level]"
+ />
+ </project-setting-row>
</template>
</div>
<project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index cfca9d400e3..4c687859344 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -1,17 +1,16 @@
import { s__, __ } from '~/locale';
-
-export const visibilityOptions = {
- PRIVATE: 0,
- INTERNAL: 10,
- PUBLIC: 20,
-};
+import {
+ VISIBILITY_LEVEL_PRIVATE_INTEGER,
+ VISIBILITY_LEVEL_INTERNAL_INTEGER,
+ VISIBILITY_LEVEL_PUBLIC_INTEGER,
+} from '~/visibility_level/constants';
export const visibilityLevelDescriptions = {
- [visibilityOptions.PRIVATE]: __(
+ [VISIBILITY_LEVEL_PRIVATE_INTEGER]: __(
`Only accessible by %{membersPageLinkStart}project members%{membersPageLinkEnd}. Membership must be explicitly granted to each user.`,
),
- [visibilityOptions.INTERNAL]: __('Accessible by any user who is logged in.'),
- [visibilityOptions.PUBLIC]: __('Accessible by anyone, regardless of authentication.'),
+ [VISIBILITY_LEVEL_INTERNAL_INTEGER]: __('Accessible by any user who is logged in.'),
+ [VISIBILITY_LEVEL_PUBLIC_INTEGER]: __('Accessible by anyone, regardless of authentication.'),
};
export const featureAccessLevel = {
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
index e92f386a29e..10b95fd6f3c 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
@@ -87,7 +87,7 @@ export default {
v-else-if="!loadingContentFailed && !isLoadingContent"
ref="content"
data-qa-selector="wiki_page_content"
- data-testid="wiki_page_content"
+ data-testid="wiki-page-content"
class="js-wiki-page-content md"
v-html="content /* eslint-disable-line vue/no-v-html */"
></div>
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 9d7d9e376cf..9acc1cb62a1 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -5,7 +5,6 @@ import {
GlLink,
GlButton,
GlSprintf,
- GlAlert,
GlFormGroup,
GlFormInput,
GlFormSelect,
@@ -59,14 +58,6 @@ export default {
label: s__('WikiPage|Content'),
placeholder: s__('WikiPage|Write your content or drag files here…'),
},
- contentEditor: {
- renderFailed: {
- message: s__(
- 'WikiPage|An error occurred while trying to render the content editor. Please try again later.',
- ),
- primaryAction: s__('WikiPage|Retry'),
- },
- },
linksHelpText: s__(
'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.',
),
@@ -88,7 +79,6 @@ export default {
{ text: s__('Wiki Page|Rich text'), value: 'richText' },
],
components: {
- GlAlert,
GlIcon,
GlForm,
GlFormGroup,
@@ -115,14 +105,12 @@ export default {
content: this.pageInfo.content || '',
commitMessage: '',
isDirty: false,
- contentEditorRenderFailed: false,
contentEditorEmpty: false,
switchEditingControlDisabled: false,
};
},
computed: {
noContent() {
- if (this.isContentEditorActive) return this.contentEditorEmpty;
return !this.content.trim();
},
csrfToken() {
@@ -145,11 +133,6 @@ export default {
linkExample() {
return MARKDOWN_LINK_TEXT[this.format];
},
- toggleEditingModeButtonText() {
- return this.isContentEditorActive
- ? this.$options.i18n.editSourceButtonText
- : this.$options.i18n.editRichTextButtonText;
- },
submitButtonText() {
return this.pageInfo.persisted
? this.$options.i18n.submitButton.existingPage
@@ -177,7 +160,7 @@ export default {
return !this.isContentEditorActive;
},
disableSubmitButton() {
- return this.noContent || !this.title || this.contentEditorRenderFailed;
+ return this.noContent || !this.title;
},
isContentEditorActive() {
return this.isMarkdownFormat && this.useContentEditor;
@@ -201,23 +184,14 @@ export default {
.then(({ data }) => data.body);
},
- toggleEditingMode(editingMode) {
+ setEditingMode(editingMode) {
this.editingMode = editingMode;
- if (!this.useContentEditor && this.contentEditor) {
- this.content = this.contentEditor.getSerializedContent();
- }
- },
-
- setEditingMode(value) {
- this.editingMode = value;
},
async handleFormSubmit(e) {
e.preventDefault();
if (this.useContentEditor) {
- this.content = this.contentEditor.getSerializedContent();
-
this.trackFormSubmit();
}
@@ -235,30 +209,10 @@ export default {
this.isDirty = true;
},
- async loadInitialContent(contentEditor) {
- this.contentEditor = contentEditor;
-
- try {
- await this.contentEditor.setSerializedContent(this.content);
- this.trackContentEditorLoaded();
- } catch (e) {
- this.contentEditorRenderFailed = true;
- }
- },
-
- async retryInitContentEditor() {
- try {
- this.contentEditorRenderFailed = false;
- await this.contentEditor.setSerializedContent(this.content);
- } catch (e) {
- this.contentEditorRenderFailed = true;
- }
- },
-
- handleContentEditorChange({ empty }) {
+ handleContentEditorChange({ empty, markdown, changed }) {
this.contentEditorEmpty = empty;
- // TODO: Implement a precise mechanism to detect changes in the Content
- this.isDirty = true;
+ this.isDirty = changed;
+ this.content = markdown;
},
onPageUnload(event) {
@@ -320,17 +274,6 @@ export default {
class="wiki-form common-note-form gl-mt-3 js-quick-submit"
@submit="handleFormSubmit"
>
- <gl-alert
- v-if="isContentEditorActive && contentEditorRenderFailed"
- class="gl-mb-6"
- :dismissible="false"
- variant="danger"
- :primary-button-text="$options.i18n.contentEditor.renderFailed.primaryAction"
- @primaryAction="retryInitContentEditor"
- >
- {{ $options.i18n.contentEditor.renderFailed.message }}
- </gl-alert>
-
<input :value="csrfToken" type="hidden" name="authenticity_token" />
<input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" />
<input
@@ -350,7 +293,6 @@ export default {
{{ $options.i18n.title.helpText.learnMore }}
</gl-link>
</template>
-
<gl-form-input
id="wiki_title"
v-model="title"
@@ -395,7 +337,7 @@ export default {
:checked="editingMode"
:options="$options.switchEditingControlOptions"
:disabled="switchEditingControlDisabled"
- @input="toggleEditingMode"
+ @input="setEditingMode"
/>
</div>
<local-storage-sync
@@ -436,13 +378,20 @@ export default {
<content-editor
:render-markdown="renderMarkdown"
:uploads-path="pageInfo.uploadsPath"
- @initialized="loadInitialContent"
+ :markdown="content"
+ @initialized="trackContentEditorLoaded"
@change="handleContentEditorChange"
@loading="disableSwitchEditingControl"
@loadingSuccess="enableSwitchEditingControl"
@loadingError="enableSwitchEditingControl"
/>
- <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
+ <input
+ id="wiki_content"
+ v-model.trim="content"
+ type="hidden"
+ name="wiki[content]"
+ data-qa-selector="wiki_hidden_content"
+ />
</div>
<div class="clearfix"></div>
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 94506d33b33..9e0af426f6e 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -1,8 +1,8 @@
import { select } from 'd3-selection';
-import dateFormat from 'dateformat';
import $ from 'jquery';
import { last } from 'lodash';
import createFlash from '~/flash';
+import dateFormat from '~/lib/dateformat';
import axios from '~/lib/utils/axios_utils';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
diff --git a/app/assets/javascripts/pages/users/user_overview_block.js b/app/assets/javascripts/pages/users/user_overview_block.js
index a7c3c9d104d..8d2d66d812e 100644
--- a/app/assets/javascripts/pages/users/user_overview_block.js
+++ b/app/assets/javascripts/pages/users/user_overview_block.js
@@ -33,6 +33,7 @@ export default class UserOverviewBlock {
const containerEl = document.querySelector(this.container);
const contentList = containerEl.querySelector('.overview-content-list');
+ // eslint-disable-next-line no-unsanitized/property
contentList.innerHTML += html;
const loadingEl = containerEl.querySelector('.loading');
diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
index 644eccc0232..ddc880db227 100644
--- a/app/assets/javascripts/pdf/index.vue
+++ b/app/assets/javascripts/pdf/index.vue
@@ -2,10 +2,10 @@
import pdfjsLib from 'pdfjs-dist/build/pdf';
import workerSrc from 'pdfjs-dist/build/pdf.worker.min';
-import page from './page/index.vue';
+import Page from './page/index.vue';
export default {
- components: { page },
+ components: { Page },
props: {
pdf: {
type: [String, Uint8Array],
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 9cea89f4990..7e331bdd91d 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -7,12 +7,13 @@ const DEFERRED_LINK_CLASS = 'deferred-link';
export default class PersistentUserCallout {
constructor(container, options = container.dataset) {
- const { dismissEndpoint, featureId, groupId, namespaceId, deferLinks } = options;
+ const { dismissEndpoint, featureId, groupId, namespaceId, projectId, deferLinks } = options;
this.container = container;
this.dismissEndpoint = dismissEndpoint;
this.featureId = featureId;
this.groupId = groupId;
this.namespaceId = namespaceId;
+ this.projectId = projectId;
this.deferLinks = parseBoolean(deferLinks);
this.closeButtons = this.container.querySelectorAll('.js-close');
@@ -58,6 +59,7 @@ export default class PersistentUserCallout {
feature_name: this.featureId,
group_id: this.groupId,
namespace_id: this.namespaceId,
+ project_id: this.projectId,
})
.then(() => {
this.container.remove();
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index ead512e3574..2580cbcb944 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -17,6 +17,9 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-submit-license-usage-data-banner',
'.js-project-usage-limitations-callout',
'.js-namespace-storage-alert',
+ '.js-web-hook-disabled-callout',
+ '.js-merge-request-settings-callout',
+ '.js-ultimate-feature-removal-banner',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
index 4398ba67d47..1f8ddae3696 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -237,7 +237,7 @@ export default {
v-for="branch in availableBranches"
:key="branch"
:is-checked="currentBranch === branch"
- :is-check-item="true"
+ is-check-item
data-qa-selector="branch_menu_item_button"
@click="selectBranch(branch)"
>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
index 7beabcfe403..feadc60a22a 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
@@ -1,6 +1,6 @@
<script>
import { __ } from '~/locale';
-import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
import { PIPELINE_FAILURE } from '../../constants';
@@ -10,8 +10,6 @@ export default {
},
components: {
PipelineMiniGraph,
- LinkedPipelinesMiniList: () =>
- import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
},
inject: ['projectFullPath'],
props: {
@@ -47,9 +45,6 @@ export default {
downstreamPipelines() {
return this.linkedPipelines?.downstream?.nodes || [];
},
- hasDownstreamPipelines() {
- return this.downstreamPipelines.length > 0;
- },
hasPipelineStages() {
return this.pipelineStages.length > 0;
},
@@ -87,23 +82,11 @@ export default {
</script>
<template>
- <div
+ <pipeline-mini-graph
v-if="hasPipelineStages"
- class="gl-align-items-center gl-display-inline-flex gl-flex-wrap stage-cell gl-mr-5"
- >
- <linked-pipelines-mini-list
- v-if="upstreamPipeline"
- :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
- upstreamPipeline,
- ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- data-testid="pipeline-editor-mini-graph-upstream"
- />
- <pipeline-mini-graph :stages="pipelineStages" />
- <linked-pipelines-mini-list
- v-if="hasDownstreamPipelines"
- :triggered="downstreamPipelines"
- :pipeline-path="pipelinePath"
- data-testid="pipeline-editor-mini-graph-downstream"
- />
- </div>
+ :downstream-pipelines="downstreamPipelines"
+ :pipeline-path="pipelinePath"
+ :stages="pipelineStages"
+ :upstream-pipeline="upstreamPipeline"
+ />
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index 4b9c98135ec..137dfca68d6 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -174,7 +174,7 @@ export default {
<div class="gl-display-flex gl-flex-wrap">
<pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" />
<gl-button
- class="gl-mt-2 gl-md-mt-0"
+ class="gl-ml-3"
category="secondary"
variant="confirm"
:href="status.detailsPath"
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index 3fd31edec2c..548769eb214 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -47,6 +47,7 @@ export default {
currentCiFileContent: '',
failureType: null,
failureReasons: [],
+ hasBranchLoaded: false,
initialCiFileContent: '',
isFetchingCommitSha: false,
isLintUnavailable: false,
@@ -234,7 +235,7 @@ export default {
return this.lastCommittedContent !== this.currentCiFileContent;
},
isBlobContentLoading() {
- return this.$apollo.queries.initialCiFileContent.loading;
+ return !this.hasBranchLoaded || this.$apollo.queries.initialCiFileContent.loading;
},
isCiConfigDataLoading() {
return this.$apollo.queries.ciConfigData.loading;
@@ -243,7 +244,7 @@ export default {
return this.currentCiFileContent === '';
},
shouldSkipBlobContentQuery() {
- return this.isNewCiConfigFile || this.lastCommittedContent || !this.currentBranch;
+ return this.isNewCiConfigFile || this.lastCommittedContent || !this.hasBranchLoaded;
},
shouldSkipCiConfigQuery() {
return !this.currentCiFileContent || !this.commitSha;
@@ -264,6 +265,17 @@ export default {
},
},
watch: {
+ currentBranch: {
+ immediate: true,
+ handler(branch) {
+ // currentBranch is a client query so it starts off undefined. In the index.js,
+ // write to the apollo cache. Once that operation is done, we can safely do operations
+ // that require the branch to have loaded.
+ if (branch) {
+ this.hasBranchLoaded = true;
+ }
+ },
+ },
isEmpty(flag) {
if (flag) {
this.setAppStatus(EDITOR_APP_STATUS_EMPTY);
diff --git a/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue
new file mode 100644
index 00000000000..529ec4897b4
--- /dev/null
+++ b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue
@@ -0,0 +1,490 @@
+<script>
+import {
+ GlAlert,
+ GlIcon,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlLink,
+ GlSprintf,
+ GlLoadingIcon,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { uniqueId } from 'lodash';
+import Vue from 'vue';
+import axios from '~/lib/utils/axios_utils';
+import { backOff } from '~/lib/utils/common_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { s__, __, n__ } from '~/locale';
+import {
+ VARIABLE_TYPE,
+ FILE_TYPE,
+ CONFIG_VARIABLES_TIMEOUT,
+ CC_VALIDATION_REQUIRED_ERROR,
+} from '../constants';
+import filterVariables from '../utils/filter_variables';
+import RefsDropdown from './refs_dropdown.vue';
+
+const i18n = {
+ variablesDescription: s__(
+ 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
+ ),
+ defaultError: __('Something went wrong on our end. Please try again.'),
+ refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'),
+ submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'),
+ warningTitle: __('The form contains the following warning:'),
+ maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
+ removeVariableLabel: s__('CiVariables|Remove variable'),
+};
+
+export default {
+ typeOptions: {
+ [VARIABLE_TYPE]: __('Variable'),
+ [FILE_TYPE]: __('File'),
+ },
+ i18n,
+ formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0',
+ // this height value is used inline on the textarea to match the input field height
+ // it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used
+ textAreaStyle: { height: '32px' },
+ components: {
+ GlAlert,
+ GlIcon,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlLink,
+ GlSprintf,
+ GlLoadingIcon,
+ RefsDropdown,
+ CcValidationRequiredAlert: () =>
+ import('ee_component/billings/components/cc_validation_required_alert.vue'),
+ },
+ directives: { SafeHtml },
+ props: {
+ pipelinesPath: {
+ type: String,
+ required: true,
+ },
+ configVariablesPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ settingsLink: {
+ type: String,
+ required: true,
+ },
+ fileParams: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ refParam: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ variableParams: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ maxWarnings: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ refValue: {
+ shortName: this.refParam,
+ },
+ form: {},
+ errorTitle: null,
+ error: null,
+ warnings: [],
+ totalWarnings: 0,
+ isWarningDismissed: false,
+ isLoading: false,
+ submitted: false,
+ ccAlertDismissed: false,
+ };
+ },
+ computed: {
+ overMaxWarningsLimit() {
+ return this.totalWarnings > this.maxWarnings;
+ },
+ warningsSummary() {
+ return n__('%d warning found:', '%d warnings found:', this.warnings.length);
+ },
+ summaryMessage() {
+ return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary;
+ },
+ shouldShowWarning() {
+ return this.warnings.length > 0 && !this.isWarningDismissed;
+ },
+ refShortName() {
+ return this.refValue.shortName;
+ },
+ refFullName() {
+ return this.refValue.fullName;
+ },
+ variables() {
+ return this.form[this.refFullName]?.variables ?? [];
+ },
+ descriptions() {
+ return this.form[this.refFullName]?.descriptions ?? {};
+ },
+ ccRequiredError() {
+ return this.error === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed;
+ },
+ },
+ watch: {
+ refValue() {
+ this.loadConfigVariablesForm();
+ },
+ },
+ created() {
+ // this is needed until we add support for ref type in url query strings
+ // ensure default branch is called with full ref on load
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/287815
+ if (this.refValue.shortName === this.defaultBranch) {
+ this.refValue.fullName = `refs/heads/${this.refValue.shortName}`;
+ }
+
+ this.loadConfigVariablesForm();
+ },
+ methods: {
+ addEmptyVariable(refValue) {
+ const { variables } = this.form[refValue];
+
+ const lastVar = variables[variables.length - 1];
+ if (lastVar?.key === '' && lastVar?.value === '') {
+ return;
+ }
+
+ variables.push({
+ uniqueId: uniqueId(`var-${refValue}`),
+ variable_type: VARIABLE_TYPE,
+ key: '',
+ value: '',
+ });
+ },
+ setVariable(refValue, type, key, value) {
+ const { variables } = this.form[refValue];
+
+ const variable = variables.find((v) => v.key === key);
+ if (variable) {
+ variable.type = type;
+ variable.value = value;
+ } else {
+ variables.push({
+ uniqueId: uniqueId(`var-${refValue}`),
+ key,
+ value,
+ variable_type: type,
+ });
+ }
+ },
+ setVariableType(key, type) {
+ const { variables } = this.form[this.refFullName];
+ const variable = variables.find((v) => v.key === key);
+ variable.variable_type = type;
+ },
+ setVariableParams(refValue, type, paramsObj) {
+ Object.entries(paramsObj).forEach(([key, value]) => {
+ this.setVariable(refValue, type, key, value);
+ });
+ },
+ removeVariable(index) {
+ this.variables.splice(index, 1);
+ },
+ canRemove(index) {
+ return index < this.variables.length - 1;
+ },
+ loadConfigVariablesForm() {
+ // Skip when variables already cached in `form`
+ if (this.form[this.refFullName]) {
+ return;
+ }
+
+ this.fetchConfigVariables(this.refFullName || this.refShortName)
+ .then(({ descriptions, params }) => {
+ Vue.set(this.form, this.refFullName, {
+ variables: [],
+ descriptions,
+ });
+
+ // Add default variables from yml
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, params);
+ })
+ .catch(() => {
+ Vue.set(this.form, this.refFullName, {
+ variables: [],
+ descriptions: {},
+ });
+ })
+ .finally(() => {
+ // Add/update variables, e.g. from query string
+ if (this.variableParams) {
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams);
+ }
+ if (this.fileParams) {
+ this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams);
+ }
+
+ // Adds empty var at the end of the form
+ this.addEmptyVariable(this.refFullName);
+ });
+ },
+ fetchConfigVariables(refValue) {
+ this.isLoading = true;
+
+ return backOff((next, stop) => {
+ axios
+ .get(this.configVariablesPath, {
+ params: {
+ sha: refValue,
+ },
+ })
+ .then(({ data, status }) => {
+ if (status === httpStatusCodes.NO_CONTENT) {
+ next();
+ } else {
+ this.isLoading = false;
+ stop(data);
+ }
+ })
+ .catch((error) => {
+ stop(error);
+ });
+ }, CONFIG_VARIABLES_TIMEOUT)
+ .then((data) => {
+ const params = {};
+ const descriptions = {};
+
+ Object.entries(data).forEach(([key, { value, description }]) => {
+ if (description) {
+ params[key] = value;
+ descriptions[key] = description;
+ }
+ });
+
+ return { params, descriptions };
+ })
+ .catch((error) => {
+ this.isLoading = false;
+
+ Sentry.captureException(error);
+
+ return { params: {}, descriptions: {} };
+ });
+ },
+ createPipeline() {
+ this.submitted = true;
+ this.ccAlertDismissed = false;
+
+ return axios
+ .post(this.pipelinesPath, {
+ // send shortName as fall back for query params
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/287815
+ ref: this.refValue.fullName || this.refShortName,
+ variables_attributes: filterVariables(this.variables),
+ })
+ .then(({ data }) => {
+ redirectTo(`${this.pipelinesPath}/${data.id}`);
+ })
+ .catch((err) => {
+ // always re-enable submit button
+ this.submitted = false;
+
+ const {
+ errors = [],
+ warnings = [],
+ total_warnings: totalWarnings = 0,
+ } = err.response.data;
+ const [error] = errors;
+
+ this.reportError({
+ title: i18n.submitErrorTitle,
+ error,
+ warnings,
+ totalWarnings,
+ });
+ });
+ },
+ onRefsLoadingError(error) {
+ this.reportError({ title: i18n.refsLoadingErrorTitle });
+
+ Sentry.captureException(error);
+ },
+ reportError({ title = null, error = i18n.defaultError, warnings = [], totalWarnings = 0 }) {
+ this.errorTitle = title;
+ this.error = error;
+ this.warnings = warnings;
+ this.totalWarnings = totalWarnings;
+ },
+ dismissError() {
+ this.ccAlertDismissed = true;
+ this.error = null;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form @submit.prevent="createPipeline">
+ <cc-validation-required-alert v-if="ccRequiredError" class="gl-pb-5" @dismiss="dismissError" />
+ <gl-alert
+ v-else-if="error"
+ :title="errorTitle"
+ :dismissible="false"
+ variant="danger"
+ class="gl-mb-4"
+ data-testid="run-pipeline-error-alert"
+ >
+ <span v-safe-html="error"></span>
+ </gl-alert>
+ <gl-alert
+ v-if="shouldShowWarning"
+ :title="$options.i18n.warningTitle"
+ variant="warning"
+ class="gl-mb-4"
+ data-testid="run-pipeline-warning-alert"
+ @dismiss="isWarningDismissed = true"
+ >
+ <details>
+ <summary>
+ <gl-sprintf :message="summaryMessage">
+ <template #total>
+ {{ totalWarnings }}
+ </template>
+ <template #warningsDisplayed>
+ {{ maxWarnings }}
+ </template>
+ </gl-sprintf>
+ </summary>
+ <p
+ v-for="(warning, index) in warnings"
+ :key="`warning-${index}`"
+ data-testid="run-pipeline-warning"
+ >
+ {{ warning }}
+ </p>
+ </details>
+ </gl-alert>
+ <gl-form-group :label="s__('Pipeline|Run for branch name or tag')">
+ <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" />
+ </gl-form-group>
+
+ <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" />
+
+ <gl-form-group v-else :label="s__('Pipeline|Variables')">
+ <div
+ v-for="(variable, index) in variables"
+ :key="variable.uniqueId"
+ class="gl-mb-3 gl-ml-n3 gl-pb-2"
+ data-testid="ci-variable-row"
+ data-qa-selector="ci_variable_row_container"
+ >
+ <div
+ class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row"
+ >
+ <gl-dropdown
+ :text="$options.typeOptions[variable.variable_type]"
+ :class="$options.formElementClasses"
+ data-testid="pipeline-form-ci-variable-type"
+ >
+ <gl-dropdown-item
+ v-for="type in Object.keys($options.typeOptions)"
+ :key="type"
+ @click="setVariableType(variable.key, type)"
+ >
+ {{ $options.typeOptions[type] }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <gl-form-input
+ v-model="variable.key"
+ :placeholder="s__('CiVariables|Input variable key')"
+ :class="$options.formElementClasses"
+ data-testid="pipeline-form-ci-variable-key"
+ data-qa-selector="ci_variable_key_field"
+ @change="addEmptyVariable(refFullName)"
+ />
+ <gl-form-textarea
+ v-model="variable.value"
+ :placeholder="s__('CiVariables|Input variable value')"
+ class="gl-mb-3"
+ :style="$options.textAreaStyle"
+ :no-resize="false"
+ data-testid="pipeline-form-ci-variable-value"
+ data-qa-selector="ci_variable_value_field"
+ />
+
+ <template v-if="variables.length > 1">
+ <gl-button
+ v-if="canRemove(index)"
+ class="gl-md-ml-3 gl-mb-3"
+ data-testid="remove-ci-variable-row"
+ variant="danger"
+ category="secondary"
+ :aria-label="$options.i18n.removeVariableLabel"
+ @click="removeVariable(index)"
+ >
+ <gl-icon class="gl-mr-0! gl-display-none gl-md-display-block" name="clear" />
+ <span class="gl-md-display-none">{{ $options.i18n.removeVariableLabel }}</span>
+ </gl-button>
+ <gl-button
+ v-else
+ class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden"
+ icon="clear"
+ :aria-label="$options.i18n.removeVariableLabel"
+ />
+ </template>
+ </div>
+ <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3">
+ {{ descriptions[variable.key] }}
+ </div>
+ </div>
+
+ <template #description
+ ><gl-sprintf :message="$options.i18n.variablesDescription">
+ <template #link="{ content }">
+ <gl-link :href="settingsLink">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf></template
+ >
+ </gl-form-group>
+ <div class="gl-pt-5 gl-display-flex">
+ <gl-button
+ type="submit"
+ category="primary"
+ variant="confirm"
+ class="js-no-auto-disable gl-mr-3"
+ data-qa-selector="run_pipeline_button"
+ data-testid="run_pipeline_button"
+ :disabled="submitted"
+ >{{ s__('Pipeline|Run pipeline') }}</gl-button
+ >
+ <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index 9378b67b915..529ec4897b4 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -282,7 +282,7 @@ export default {
const descriptions = {};
Object.entries(data).forEach(([key, { value, description }]) => {
- if (description !== null) {
+ if (description) {
params[key] = value;
descriptions[key] = description;
}
diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js
index 927eeb5e144..e3f363f4ada 100644
--- a/app/assets/javascripts/pipeline_new/index.js
+++ b/app/assets/javascripts/pipeline_new/index.js
@@ -1,27 +1,72 @@
import Vue from 'vue';
+import LegacyPipelineNewForm from './components/legacy_pipeline_new_form.vue';
import PipelineNewForm from './components/pipeline_new_form.vue';
-export default () => {
- const el = document.getElementById('js-new-pipeline');
+const mountLegacyPipelineNewForm = (el) => {
const {
// provide/inject
projectRefsEndpoint,
// props
- projectId,
- pipelinesPath,
configVariablesPath,
defaultBranch,
+ fileParam,
+ maxWarnings,
+ pipelinesPath,
+ projectId,
refParam,
+ settingsLink,
varParam,
+ } = el.dataset;
+
+ const variableParams = JSON.parse(varParam);
+ const fileParams = JSON.parse(fileParam);
+
+ return new Vue({
+ el,
+ provide: {
+ projectRefsEndpoint,
+ },
+ render(createElement) {
+ return createElement(LegacyPipelineNewForm, {
+ props: {
+ configVariablesPath,
+ defaultBranch,
+ fileParams,
+ maxWarnings: Number(maxWarnings),
+ pipelinesPath,
+ projectId,
+ refParam,
+ settingsLink,
+ variableParams,
+ },
+ });
+ },
+ });
+};
+
+const mountPipelineNewForm = (el) => {
+ const {
+ // provide/inject
+ projectRefsEndpoint,
+
+ // props
+ configVariablesPath,
+ defaultBranch,
fileParam,
- settingsLink,
maxWarnings,
+ pipelinesPath,
+ projectId,
+ refParam,
+ settingsLink,
+ varParam,
} = el.dataset;
const variableParams = JSON.parse(varParam);
const fileParams = JSON.parse(fileParam);
+ // TODO: add apolloProvider
+
return new Vue({
el,
provide: {
@@ -30,17 +75,27 @@ export default () => {
render(createElement) {
return createElement(PipelineNewForm, {
props: {
- projectId,
- pipelinesPath,
configVariablesPath,
defaultBranch,
- refParam,
- variableParams,
fileParams,
- settingsLink,
maxWarnings: Number(maxWarnings),
+ pipelinesPath,
+ projectId,
+ refParam,
+ settingsLink,
+ variableParams,
},
});
},
});
};
+
+export default () => {
+ const el = document.getElementById('js-new-pipeline');
+
+ if (gon.features?.runPipelineGraphql) {
+ mountPipelineNewForm(el);
+ } else {
+ mountLegacyPipelineNewForm(el);
+ }
+};
diff --git a/app/assets/javascripts/pipeline_wizard/components/editor.vue b/app/assets/javascripts/pipeline_wizard/components/editor.vue
index 41611233f71..0c063241173 100644
--- a/app/assets/javascripts/pipeline_wizard/components/editor.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/editor.vue
@@ -27,7 +27,7 @@ export default {
data() {
return {
editor: null,
- isUpdating: false,
+ isFocused: false,
yamlEditorExtension: null,
};
},
@@ -60,19 +60,23 @@ export default {
this.editor.onDidChangeModelContent(
debounce(() => this.handleChange(), CONTENT_UPDATE_DEBOUNCE),
);
+ this.editor.onDidFocusEditorText(() => {
+ this.isFocused = true;
+ });
+ this.editor.onDidBlurEditorText(() => {
+ this.isFocused = false;
+ });
this.updateEditorContent();
this.emitValue();
},
methods: {
async updateEditorContent() {
- this.isUpdating = true;
this.editor.setDoc(this.doc);
- this.isUpdating = false;
this.requestHighlight(this.highlight);
},
handleChange() {
this.emitValue();
- if (!this.isUpdating) {
+ if (this.isFocused) {
this.handleTouch();
}
},
diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
index 0fe87bcee7b..adeb4ae598b 100644
--- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
@@ -5,6 +5,7 @@ import { uniqueId } from 'lodash';
import { merge } from '~/lib/utils/yaml';
import { __ } from '~/locale';
import { isValidStepSeq } from '~/pipeline_wizard/validators';
+import Tracking from '~/tracking';
import YamlEditor from './editor.vue';
import WizardStep from './step.vue';
import CommitStep from './commit.vue';
@@ -16,6 +17,8 @@ export const i18n = {
YAML-file for you to add to your repository`),
};
+const trackingMixin = Tracking.mixin();
+
export default {
name: 'PipelineWizardWrapper',
i18n,
@@ -25,6 +28,7 @@ export default {
WizardStep,
CommitStep,
},
+ mixins: [trackingMixin],
props: {
steps: {
type: Object,
@@ -43,6 +47,11 @@ export default {
type: String,
required: true,
},
+ templateId: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -77,6 +86,11 @@ export default {
template: this.steps.get(i).get('template', true),
}));
},
+ tracking() {
+ return {
+ category: `pipeline_wizard:${this.templateId}`,
+ };
+ },
},
watch: {
isLastStep(value) {
@@ -84,9 +98,6 @@ export default {
},
},
methods: {
- getStep(index) {
- return this.steps.get(index);
- },
resetHighlight() {
this.highlightPath = null;
},
@@ -106,6 +117,43 @@ export default {
});
return doc;
},
+ onBack() {
+ this.currentStepIndex -= 1;
+ this.track('click_button', {
+ property: 'back',
+ label: 'pipeline_wizard_navigation',
+ extra: {
+ fromStep: this.currentStepIndex + 1,
+ toStep: this.currentStepIndex,
+ },
+ });
+ },
+ onNext() {
+ this.currentStepIndex += 1;
+ this.track('click_button', {
+ property: 'next',
+ label: 'pipeline_wizard_navigation',
+ extra: {
+ fromStep: this.currentStepIndex - 1,
+ toStep: this.currentStepIndex,
+ },
+ });
+ },
+ onDone() {
+ this.$emit('done');
+ this.track('click_button', {
+ label: 'pipeline_wizard_commit',
+ property: 'commit',
+ });
+ },
+ onEditorTouched() {
+ this.track('edit', {
+ label: 'pipeline_wizard_editor_interaction',
+ extra: {
+ currentStep: this.currentStepIndex,
+ },
+ });
+ },
},
};
</script>
@@ -127,8 +175,8 @@ export default {
:file-content="pipelineBlob"
:filename="filename"
:project-path="projectPath"
- @back="currentStepIndex--"
- @done="$emit('done')"
+ @back="onBack"
+ @done="onDone"
/>
<wizard-step
v-for="(step, i) in stepList"
@@ -141,8 +189,8 @@ export default {
:highlight.sync="highlightPath"
:inputs="step.inputs"
:template="step.template"
- @back="currentStepIndex--"
- @next="currentStepIndex++"
+ @back="onBack"
+ @next="onNext"
@update:compiled="onUpdate"
/>
</section>
@@ -162,6 +210,7 @@ export default {
:highlight="highlightPath"
class="gl-w-full"
@update:yaml="onEditorUpdate"
+ @touch.once="onEditorTouched"
/>
<div
v-if="showPlaceholder"
diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
index 79b1507ad0e..5a93de3b1be 100644
--- a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
+++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
@@ -42,6 +42,9 @@ export default {
steps() {
return this.parsedTemplate?.get('steps');
},
+ templateId() {
+ return this.parsedTemplate?.get('id');
+ },
},
};
</script>
@@ -60,6 +63,7 @@ export default {
:filename="filename"
:project-path="projectPath"
:steps="steps"
+ :template-id="templateId"
@done="$emit('done')"
/>
</div>
diff --git a/app/assets/javascripts/pipeline_wizard/templates/pages.yml b/app/assets/javascripts/pipeline_wizard/templates/pages.yml
index cd2242b1ba7..9d7936f2f5a 100644
--- a/app/assets/javascripts/pipeline_wizard/templates/pages.yml
+++ b/app/assets/javascripts/pipeline_wizard/templates/pages.yml
@@ -1,3 +1,4 @@
+id: gitlab/pages
title: Get started with Pages
description: "GitLab Pages lets you deploy static websites in minutes. All you
need is a .gitlab-ci.yml file. Follow the below steps to
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 31a34ab4fb5..1a05710a13e 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -170,7 +170,7 @@ export default {
ref="mainPipelineContainer"
class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap"
:class="{
- 'gl-pipeline-min-h gl-py-5 gl-overflow-auto gl-border-t-solid gl-border-t-1 gl-border-gray-100': !isLinkedPipeline,
+ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline,
}"
>
<linked-graph-wrapper>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index 14872c34afb..f822e2c0874 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -281,7 +281,6 @@ export default {
:type="graphViewType"
:show-links="showLinks"
:tip-previously-dismissed="hoverTipPreviouslyDismissed"
- :is-pipeline-complete="pipeline.complete"
@dismissHoverTip="handleTipDismissal"
@updateViewType="updateViewType"
@updateShowLinksState="updateShowLinksState"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
index a8c5d85f4ed..6d8c35f4482 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -1,33 +1,19 @@
<script>
-import {
- GlAlert,
- GlButton,
- GlButtonGroup,
- GlLoadingIcon,
- GlToggle,
- GlModalDirective,
-} from '@gitlab/ui';
+import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import Tracking from '~/tracking';
-import PerformanceInsightsModal from '../performance_insights_modal.vue';
-import { performanceModalId } from '../../constants';
import { STAGE_VIEW, LAYER_VIEW } from './constants';
export default {
name: 'GraphViewSelector',
- performanceModalId,
+
components: {
GlAlert,
GlButton,
GlButtonGroup,
GlLoadingIcon,
GlToggle,
- PerformanceInsightsModal,
- },
- directives: {
- GlModal: GlModalDirective,
},
- mixins: [Tracking.mixin()],
+
props: {
showLinks: {
type: Boolean,
@@ -41,10 +27,6 @@ export default {
type: String,
required: true,
},
- isPipelineComplete: {
- type: Boolean,
- required: true,
- },
},
data() {
return {
@@ -59,7 +41,6 @@ export default {
hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'),
linksLabelText: s__('GraphViewType|Show dependencies'),
viewLabelText: __('Group jobs by'),
- performanceBtnText: __('Performance insights'),
},
views: {
[STAGE_VIEW]: {
@@ -150,9 +131,6 @@ export default {
this.$emit('updateShowLinksState', val);
});
},
- trackInsightsClick() {
- this.track('click_insights_button', { label: 'performance_insights' });
- },
},
};
</script>
@@ -178,15 +156,6 @@ export default {
</gl-button>
</gl-button-group>
- <gl-button
- v-if="isPipelineComplete"
- v-gl-modal="$options.performanceModalId"
- data-testid="pipeline-insights-btn"
- @click="trackInsightsClick"
- >
- {{ $options.i18n.performanceBtnText }}
- </gl-button>
-
<div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center">
<gl-toggle
v-model="showLinksActive"
@@ -202,7 +171,5 @@ export default {
<gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip">
{{ $options.i18n.hoverTipText }}
</gl-alert>
-
- <performance-insights-modal />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index 8d764fad0c5..02d0c07ea54 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -82,7 +82,9 @@ export default {
:stage-name="stageName"
/>
- <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4">{{ group.size }}</div>
+ <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4 gl-align-self-center">
+ {{ group.size }}
+ </div>
</div>
</button>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 6ab4eb58977..4aec28295bd 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,5 +1,5 @@
<script>
-import { capitalize, escape, isEmpty } from 'lodash';
+import { escape, isEmpty } from 'lodash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { reportToSentry } from '../../utils';
import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue';
@@ -64,8 +64,7 @@ export default {
},
},
jobClasses: [
- 'gl-py-3',
- 'gl-px-4',
+ 'gl-p-3',
'gl-border-gray-100',
'gl-border-solid',
'gl-border-1',
@@ -92,9 +91,6 @@ export default {
columnSpacingClass() {
return this.isStageView ? 'gl-px-6' : 'gl-px-9';
},
- formattedTitle() {
- return capitalize(escape(this.name));
- },
hasAction() {
return !isEmpty(this.action);
},
@@ -141,8 +137,8 @@ export default {
class="gl-display-flex gl-justify-content-space-between gl-relative"
:class="$options.titleClasses"
>
- <span :title="formattedTitle" class="gl-text-truncate gl-pr-3 gl-w-85p">
- {{ formattedTitle }}
+ <span :title="name" class="gl-text-truncate gl-pr-3 gl-w-85p">
+ {{ name }}
</span>
<action-component
v-if="hasAction && canUpdatePipeline"
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index fabae62fc45..a36d5d9b58f 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import ciHeader from '~/vue_shared/components/header_ci_component.vue';
+import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import {
LOAD_FAILURE,
POST_FAILURE,
@@ -33,7 +33,7 @@ export default {
pipelineRetry: 'pipelineRetry',
finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'],
components: {
- ciHeader,
+ CiHeader,
GlAlert,
GlButton,
GlLoadingIcon,
diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
index 70d1a5c08cc..f4fc6893520 100644
--- a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
@@ -1,5 +1,5 @@
<script>
-import ciIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
/**
* Component that renders both the CI icon status and the job name.
@@ -9,7 +9,7 @@ import ciIcon from '~/vue_shared/components/ci_icon.vue';
*/
export default {
components: {
- ciIcon,
+ CiIcon,
},
props: {
name: {
diff --git a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
deleted file mode 100644
index fdbf0ca19bc..00000000000
--- a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
+++ /dev/null
@@ -1,171 +0,0 @@
-<script>
-import { GlAlert, GlCard, GlLink, GlLoadingIcon, GlModal } from '@gitlab/ui';
-import { __, s__ } from '~/locale';
-import { humanizeTimeInterval } from '~/lib/utils/datetime_utility';
-import HelpPopover from '~/vue_shared/components/help_popover.vue';
-import getPerformanceInsightsQuery from '../graphql/queries/get_performance_insights.query.graphql';
-import { performanceModalId } from '../constants';
-import { calculateJobStats, calculateSlowestFiveJobs } from '../utils';
-
-export default {
- name: 'PerformanceInsightsModal',
- i18n: {
- queuedCardHeader: s__('Pipeline|Longest queued job'),
- queuedCardHelp: s__(
- 'Pipeline|The longest queued job is the job that spent the longest time in the pending state, waiting to be picked up by a Runner',
- ),
- executedCardHeader: s__('Pipeline|Last executed job'),
- executedCardHelp: s__(
- 'Pipeline|The last executed job is the last job to start in the pipeline.',
- ),
- viewDependency: s__('Pipeline|View dependency'),
- slowJobsTitle: s__('Pipeline|Five slowest jobs'),
- feeback: __('Feedback issue'),
- insightsLimit: s__('Pipeline|Only able to show first 100 results'),
- },
- modal: {
- title: s__('Pipeline|Performance insights'),
- actionCancel: {
- text: __('Close'),
- attributes: {
- variant: 'confirm',
- },
- },
- },
- performanceModalId,
- components: {
- GlAlert,
- GlCard,
- GlLink,
- GlModal,
- GlLoadingIcon,
- HelpPopover,
- },
- inject: {
- pipelineIid: {
- default: '',
- },
- pipelineProjectPath: {
- default: '',
- },
- },
- apollo: {
- jobs: {
- query: getPerformanceInsightsQuery,
- variables() {
- return {
- fullPath: this.pipelineProjectPath,
- iid: this.pipelineIid,
- };
- },
- update(data) {
- return data.project?.pipeline?.jobs;
- },
- },
- },
- data() {
- return {
- jobs: null,
- };
- },
- computed: {
- longestQueuedJob() {
- return calculateJobStats(this.jobs, 'queuedDuration');
- },
- lastExecutedJob() {
- return calculateJobStats(this.jobs, 'startedAt');
- },
- slowestFiveJobs() {
- return calculateSlowestFiveJobs(this.jobs);
- },
- queuedDurationDisplay() {
- return humanizeTimeInterval(this.longestQueuedJob.queuedDuration);
- },
- showLimitMessage() {
- return this.jobs.pageInfo.hasNextPage;
- },
- },
-};
-</script>
-
-<template>
- <gl-modal
- :modal-id="$options.performanceModalId"
- :title="$options.modal.title"
- :action-cancel="$options.modal.actionCancel"
- >
- <gl-loading-icon v-if="$apollo.queries.jobs.loading" size="lg" />
-
- <template v-else>
- <gl-alert class="gl-mb-4" :dismissible="false">
- <p v-if="showLimitMessage" data-testid="limit-alert-text">
- {{ $options.i18n.insightsLimit }}
- </p>
- <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/365902" class="gl-mt-5">
- {{ $options.i18n.feeback }}
- </gl-link>
- </gl-alert>
-
- <div class="gl-display-flex gl-justify-content-space-between gl-mt-2 gl-mb-7">
- <gl-card class="gl-w-half gl-mr-7 gl-text-center">
- <template #header>
- <span class="gl-font-weight-bold">{{ $options.i18n.queuedCardHeader }}</span>
- <help-popover>
- {{ $options.i18n.queuedCardHelp }}
- </help-popover>
- </template>
- <div class="gl-display-flex gl-flex-direction-column">
- <span
- class="gl-font-weight-bold gl-font-size-h2 gl-mb-2"
- data-testid="insights-queued-card-data"
- >
- {{ queuedDurationDisplay }}
- </span>
- <gl-link
- :href="longestQueuedJob.detailedStatus.detailsPath"
- data-testid="insights-queued-card-link"
- >
- {{ longestQueuedJob.name }}
- </gl-link>
- </div>
- </gl-card>
- <gl-card class="gl-w-half gl-text-center" data-testid="insights-executed-card">
- <template #header>
- <span class="gl-font-weight-bold">{{ $options.i18n.executedCardHeader }}</span>
- <help-popover>
- {{ $options.i18n.executedCardHelp }}
- </help-popover>
- </template>
- <div class="gl-display-flex gl-flex-direction-column">
- <span
- class="gl-font-weight-bold gl-font-size-h2 gl-mb-2"
- data-testid="insights-executed-card-data"
- >
- {{ lastExecutedJob.name }}
- </span>
- <gl-link
- :href="lastExecutedJob.detailedStatus.detailsPath"
- data-testid="insights-executed-card-link"
- >
- {{ $options.i18n.viewDependency }}
- </gl-link>
- </div>
- </gl-card>
- </div>
-
- <div class="gl-mt-7">
- <span class="gl-font-weight-bold">{{ $options.i18n.slowJobsTitle }}</span>
- <div
- v-for="job in slowestFiveJobs"
- :key="job.name"
- class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-mt-3 gl-p-4 gl-border-t-1 gl-border-t-solid gl-border-b-0 gl-border-b-solid gl-border-gray-100"
- >
- <span data-testid="insights-slow-job-stage">{{ job.stage.name }}</span>
- <gl-link :href="job.detailedStatus.detailsPath" data-testid="insights-slow-job-link">{{
- job.name
- }}</gl-link>
- </div>
- </div>
- </template>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
index 793e343a02a..3f1d7255a2b 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -1,9 +1,9 @@
<script>
-import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
- tooltipOnTruncate,
+ TooltipOnTruncate,
},
props: {
jobName: {
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
index e485b38ce11..600832b7633 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
@@ -1,10 +1,9 @@
<script>
-import { capitalize, escape } from 'lodash';
-import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
- tooltipOnTruncate,
+ TooltipOnTruncate,
},
props: {
stageName: {
@@ -12,17 +11,12 @@ export default {
required: true,
},
},
- computed: {
- formattedTitle() {
- return capitalize(escape(this.stageName));
- },
- },
};
</script>
<template>
<tooltip-on-truncate :title="stageName" truncate-target="child" placement="top">
<div class="gl-py-2 gl-text-truncate gl-font-weight-bold gl-w-20">
- {{ formattedTitle }}
+ {{ stageName }}
</div>
</tooltip-on-truncate>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js
new file mode 100644
index 00000000000..1ca9e35c008
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js
@@ -0,0 +1,14 @@
+import { get } from 'lodash';
+
+export const accessors = {
+ rest: {
+ detailedStatus: ['details', 'status'],
+ },
+ graphql: {
+ detailedStatus: 'detailedStatus',
+ },
+};
+
+export const accessValue = (pipeline, dataMethod, path) => {
+ return get(pipeline, accessors[dataMethod][path]);
+};
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue
index 670fa398536..211c5f117c7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue
@@ -158,7 +158,7 @@ export default {
:href="detailsPath"
:title="tooltipText"
:class="jobClasses"
- class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
+ class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
data-testid="job-with-link"
@click.stop="hideTooltips"
@mouseout="hideTooltips"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue
new file mode 100644
index 00000000000..a5c6dc98694
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue
@@ -0,0 +1,132 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { accessValue } from './accessors/linked_pipelines_accessors';
+/**
+ * Renders the upstream/downstream portions of the pipeline mini graph.
+ */
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ CiIcon,
+ },
+ inject: {
+ dataMethod: {
+ default: 'rest',
+ },
+ },
+ props: {
+ triggeredBy: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ triggered: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ pipelinePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ maxRenderedPipelines: 3,
+ };
+ },
+ computed: {
+ // Exactly one of these (triggeredBy and triggered) must be truthy. Never both. Never neither.
+ isUpstream() {
+ return Boolean(this.triggeredBy.length) && !this.triggered.length;
+ },
+ isDownstream() {
+ return !this.triggeredBy.length && Boolean(this.triggered.length);
+ },
+ linkedPipelines() {
+ return this.isUpstream ? this.triggeredBy : this.triggered;
+ },
+ totalPipelineCount() {
+ return this.linkedPipelines.length;
+ },
+ linkedPipelinesTrimmed() {
+ return this.totalPipelineCount > this.maxRenderedPipelines
+ ? this.linkedPipelines.slice(0, this.maxRenderedPipelines)
+ : this.linkedPipelines;
+ },
+ shouldRenderCounter() {
+ return this.isDownstream && this.linkedPipelines.length > this.maxRenderedPipelines;
+ },
+ counterLabel() {
+ return `+${this.linkedPipelines.length - this.maxRenderedPipelines}`;
+ },
+ counterTooltipText() {
+ return sprintf(s__('LinkedPipelines|%{counterLabel} more downstream pipelines'), {
+ counterLabel: this.counterLabel,
+ });
+ },
+ },
+ methods: {
+ pipelineTooltipText(pipeline) {
+ const { label } = accessValue(pipeline, this.dataMethod, 'detailedStatus');
+
+ return `${pipeline.project.name} - ${label}`;
+ },
+ pipelineStatus(pipeline) {
+ // detailedStatus is graphQL, details.status is REST
+ return pipeline?.detailedStatus || pipeline?.details?.status;
+ },
+ triggerButtonClass(pipeline) {
+ const { group } = accessValue(pipeline, this.dataMethod, 'detailedStatus');
+
+ return `ci-status-icon-${group}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <span
+ v-if="linkedPipelines"
+ :class="{
+ 'is-upstream': isUpstream,
+ 'is-downstream': isDownstream,
+ }"
+ class="linked-pipeline-mini-list gl-display-inline gl-vertical-align-middle"
+ >
+ <a
+ v-for="pipeline in linkedPipelinesTrimmed"
+ :key="pipeline.id"
+ v-gl-tooltip="{ title: pipelineTooltipText(pipeline) }"
+ :href="pipeline.path"
+ :class="triggerButtonClass(pipeline)"
+ class="linked-pipeline-mini-item gl-display-inline-block gl-h-6 gl-mr-2 gl-my-2 gl-rounded-full gl-vertical-align-middle"
+ data-testid="linked-pipeline-mini-item"
+ >
+ <ci-icon
+ is-borderless
+ is-interactive
+ css-classes="gl-rounded-full"
+ :size="24"
+ :status="pipelineStatus(pipeline)"
+ class="gl-align-items-center gl-border gl-display-inline-flex"
+ />
+ </a>
+
+ <a
+ v-if="shouldRenderCounter"
+ v-gl-tooltip="{ title: counterTooltipText }"
+ :title="counterTooltipText"
+ :href="pipelinePath"
+ class="gl-align-items-center gl-bg-gray-50 gl-display-inline-flex gl-font-sm gl-h-6 gl-justify-content-center gl-rounded-pill gl-text-decoration-none gl-text-gray-500 gl-w-7 linked-pipelines-counter linked-pipeline-mini-item"
+ data-testid="linked-pipeline-counter"
+ >
+ {{ counterLabel }}
+ </a>
+ </span>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue
new file mode 100644
index 00000000000..993fa121d89
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import PipelineStages from './pipeline_stages.vue';
+import LinkedPipelinesMiniList from './linked_pipelines_mini_list.vue';
+/**
+ * Renders the pipeline mini graph.
+ */
+export default {
+ components: {
+ GlIcon,
+ LinkedPipelinesMiniList,
+ PipelineStages,
+ },
+ arrowStyles: [
+ 'arrow-icon gl-display-inline-block gl-mx-1 gl-text-gray-500 gl-vertical-align-middle!',
+ ],
+ props: {
+ downstreamPipelines: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isMergeTrain: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ pipelinePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ stages: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ stagesClass: {
+ type: [Array, Object, String],
+ required: false,
+ default: '',
+ },
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ upstreamPipeline: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+ computed: {
+ hasDownstreamPipelines() {
+ return Boolean(this.downstreamPipelines.length);
+ },
+ },
+ methods: {
+ onPipelineActionRequestComplete() {
+ this.$emit('pipelineActionRequestComplete');
+ },
+ },
+};
+</script>
+<template>
+ <div class="stage-cell" data-testid="pipeline-mini-graph">
+ <linked-pipelines-mini-list
+ v-if="upstreamPipeline"
+ :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
+ upstreamPipeline,
+ ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ data-testid="pipeline-mini-graph-upstream"
+ />
+ <gl-icon
+ v-if="upstreamPipeline"
+ :class="$options.arrowStyles"
+ name="long-arrow"
+ data-testid="upstream-arrow-icon"
+ />
+ <pipeline-stages
+ :is-merge-train="isMergeTrain"
+ :stages="stages"
+ :update-dropdown="updateDropdown"
+ :stages-class="stagesClass"
+ data-testid="pipeline-stages"
+ @pipelineActionRequestComplete="onPipelineActionRequestComplete"
+ @miniGraphStageClick="$emit('miniGraphStageClick')"
+ />
+ <gl-icon
+ v-if="hasDownstreamPipelines"
+ :class="$options.arrowStyles"
+ name="long-arrow"
+ data-testid="downstream-arrow-icon"
+ />
+ <linked-pipelines-mini-list
+ v-if="hasDownstreamPipelines"
+ :triggered="downstreamPipelines"
+ :pipeline-path="pipelinePath"
+ data-testid="pipeline-mini-graph-downstream"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
index d7e55d36ff6..a68797a7235 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
@@ -77,6 +77,10 @@ export default {
this.isDropdownOpen = true;
this.isLoading = true;
this.fetchJobs();
+
+ // used for tracking and is separate from event hub
+ // to avoid complexity with mixin
+ this.$emit('miniGraphStageClick');
},
fetchJobs() {
axios
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue
index 05cb2ebb769..e965dc5e6b0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue
@@ -1,7 +1,7 @@
<script>
-import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue';
+import PipelineStage from './pipeline_stage.vue';
/**
- * Renders the pipeline mini graph.
+ * Renders the pipeline stages portion of the pipeline mini graph.
*/
export default {
components: {
@@ -36,7 +36,7 @@ export default {
};
</script>
<template>
- <div data-testid="pipeline-mini-graph" class="gl-display-inline gl-vertical-align-middle">
+ <div data-testid="pipeline-stages" class="gl-display-inline gl-vertical-align-middle">
<div
v-for="stage in stages"
:key="stage.name"
@@ -48,6 +48,7 @@ export default {
:update-dropdown="updateDropdown"
:is-merge-train="isMergeTrain"
@pipelineActionRequestComplete="onPipelineActionRequestComplete"
+ @miniGraphStageClick="$emit('miniGraphStageClick')"
/>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
index 05a1ceface3..2d2f649f651 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
@@ -10,6 +10,8 @@ import {
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
+import Tracking from '~/tracking';
+import { TRACKING_CATEGORIES } from '../../constants';
export const i18n = {
downloadArtifacts: __('Download artifacts'),
@@ -29,6 +31,7 @@ export default {
GlSearchBoxByType,
GlLoadingIcon,
},
+ mixins: [Tracking.mixin()],
inject: {
artifactsEndpoint: {
default: '',
@@ -60,6 +63,10 @@ export default {
},
methods: {
fetchArtifacts() {
+ // refactor tracking based on action once this dropdown supports
+ // actions other than artifacts
+ this.track('click_artifacts_dropdown', { label: TRACKING_CATEGORIES.table });
+
this.isLoading = true;
// Replace the placeholder with the ID of the pipeline we are viewing
const endpoint = this.artifactsEndpoint.replace(
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
index 7a08dacb824..dd62ffb27f7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -1,7 +1,8 @@
<script>
import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
+import Tracking from '~/tracking';
import eventHub from '../../event_hub';
-import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '../../constants';
+import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES } from '../../constants';
import PipelineMultiActions from './pipeline_multi_actions.vue';
import PipelinesManualActions from './pipelines_manual_actions.vue';
@@ -17,6 +18,7 @@ export default {
PipelineMultiActions,
PipelinesManualActions,
},
+ mixins: [Tracking.mixin()],
props: {
pipeline: {
type: Object,
@@ -52,6 +54,7 @@ export default {
},
methods: {
handleCancelClick() {
+ this.trackClick('click_cancel_button');
eventHub.$emit('openConfirmationModal', {
pipeline: this.pipeline,
endpoint: this.pipeline.cancel_path,
@@ -59,8 +62,12 @@ export default {
},
handleRetryClick() {
this.isRetrying = true;
+ this.trackClick('click_retry_button');
eventHub.$emit('retryPipeline', this.pipeline.retry_path);
},
+ trackClick(action) {
+ this.track(action, { label: TRACKING_CATEGORIES.table });
+ },
},
};
</script>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
index ef21673115e..eb70b5fbb7a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
@@ -83,9 +83,7 @@ export default {
<span class="font-weight-bold">{{ __('Pipeline') }}</span>
- <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path"
- >#{{ pipeline.id }}</a
- >
+ <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a>
<template v-if="hasRef">
{{ __('from') }}
<a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a>
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 09d588aaafd..39d41415456 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -1,9 +1,10 @@
<script>
import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
+import Tracking from '~/tracking';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import { ICONS } from '../../constants';
+import { ICONS, TRACKING_CATEGORIES } from '../../constants';
import PipelineLabels from './pipeline_labels.vue';
export default {
@@ -17,6 +18,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [Tracking.mixin()],
props: {
pipeline: {
type: Object,
@@ -114,6 +116,11 @@ export default {
return this.pipeline?.commit?.title;
},
},
+ methods: {
+ trackClick(action) {
+ this.track(action, { label: TRACKING_CATEGORIES.table });
+ },
+ },
};
</script>
<template>
@@ -125,6 +132,7 @@ export default {
:href="commitUrl"
class="commit-row-message gl-text-gray-900"
data-testid="commit-title"
+ @click="trackClick('click_commit_title')"
>{{ commitTitle }}</gl-link
>
</tooltip-on-truncate>
@@ -137,6 +145,7 @@ export default {
class="gl-text-decoration-underline gl-text-blue-600! gl-mr-3"
data-testid="pipeline-url-link"
data-qa-selector="pipeline_url_link"
+ @click="trackClick('click_pipeline_id')"
>#{{ pipeline[pipelineKey] }}</gl-link
>
<!--Commit row-->
@@ -154,11 +163,17 @@ export default {
:href="mergeRequestRef.path"
class="ref-name gl-mr-3"
data-testid="merge-request-ref"
+ @click="trackClick('click_mr_ref')"
>{{ mergeRequestRef.iid }}</gl-link
>
- <gl-link v-else :href="refUrl" class="ref-name gl-mr-3" data-testid="commit-ref-name">{{
- commitRef.name
- }}</gl-link>
+ <gl-link
+ v-else
+ :href="refUrl"
+ class="ref-name gl-mr-3"
+ data-testid="commit-ref-name"
+ @click="trackClick('click_commit_name')"
+ >{{ commitRef.name }}</gl-link
+ >
</tooltip-on-truncate>
<gl-icon
v-gl-tooltip
@@ -167,9 +182,13 @@ export default {
:title="__('Commit')"
data-testid="commit-icon"
/>
- <gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{
- commitShortSha
- }}</gl-link>
+ <gl-link
+ :href="commitUrl"
+ class="commit-sha mr-0"
+ data-testid="commit-short-sha"
+ @click="trackClick('click_commit_sha')"
+ >{{ commitShortSha }}</gl-link
+ >
<user-avatar-link
v-if="commitAuthor"
:link-href="commitAuthor.path"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 485e338f639..f9022be888a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -4,6 +4,7 @@ import { isEqual } from 'lodash';
import createFlash from '~/flash';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
+import Tracking from '~/tracking';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import {
@@ -11,6 +12,7 @@ import {
RAW_TEXT_WARNING,
FILTER_TAG_IDENTIFIER,
PipelineKeyOptions,
+ TRACKING_CATEGORIES,
} from '../../constants';
import PipelinesMixin from '../../mixins/pipelines_mixin';
import PipelinesService from '../../services/pipelines_service';
@@ -35,7 +37,7 @@ export default {
PipelinesTableComponent,
TablePagination,
},
- mixins: [PipelinesMixin],
+ mixins: [PipelinesMixin, Tracking.mixin()],
props: {
store: {
type: Object,
@@ -246,6 +248,8 @@ export default {
params = this.onChangeWithFilter(params);
this.updateContent(params);
+
+ this.track('click_filter_tabs', { label: TRACKING_CATEGORIES.tabs });
},
successCallback(resp) {
// Because we are polling & the user is interacting verify if the response received
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 4d28545a035..af089aebbbe 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
@@ -2,7 +2,9 @@
import { GlFilteredSearch } from '@gitlab/ui';
import { map } from 'lodash';
import { s__ } from '~/locale';
+import Tracking from '~/tracking';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { TRACKING_CATEGORIES } from '../../constants';
import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue';
import PipelineSourceToken from './tokens/pipeline_source_token.vue';
import PipelineStatusToken from './tokens/pipeline_status_token.vue';
@@ -19,6 +21,7 @@ export default {
components: {
GlFilteredSearch,
},
+ mixins: [Tracking.mixin()],
props: {
projectId: {
type: String,
@@ -110,6 +113,7 @@ export default {
},
methods: {
onSubmit(filters) {
+ this.track('click_filtered_search', { label: TRACKING_CATEGORIES.search });
this.$emit('filterPipelines', filters);
},
},
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
index 47fffa8a6b2..16a747f6165 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
@@ -4,8 +4,10 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { s__, __, sprintf } from '~/locale';
+import Tracking from '~/tracking';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
import eventHub from '../../event_hub';
+import { TRACKING_CATEGORIES } from '../../constants';
export default {
directives: {
@@ -17,6 +19,7 @@ export default {
GlDropdownItem,
GlIcon,
},
+ mixins: [Tracking.mixin()],
props: {
actions: {
type: Array,
@@ -66,7 +69,6 @@ export default {
createFlash({ message: __('An error occurred while making the request.') });
});
},
-
isActionDisabled(action) {
if (action.playable === undefined) {
return false;
@@ -74,6 +76,9 @@ export default {
return !action.playable;
},
+ trackClick() {
+ this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table });
+ },
},
};
</script>
@@ -86,6 +91,7 @@ export default {
right
lazy
icon="play"
+ @shown="trackClick"
>
<gl-dropdown-item
v-for="action in actions"
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 e765a8cd86c..936ae4da1ec 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,6 +1,7 @@
<script>
-import { CHILD_VIEW } from '~/pipelines/constants';
+import { CHILD_VIEW, TRACKING_CATEGORIES } from '~/pipelines/constants';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import Tracking from '~/tracking';
import PipelinesTimeago from './time_ago.vue';
export default {
@@ -8,6 +9,7 @@ export default {
CiBadge,
PipelinesTimeago,
},
+ mixins: [Tracking.mixin()],
props: {
pipeline: {
type: Object,
@@ -26,6 +28,11 @@ export default {
return this.viewType === CHILD_VIEW;
},
},
+ methods: {
+ trackClick() {
+ this.track('click_ci_status_badge', { label: TRACKING_CATEGORIES.table });
+ },
+ },
};
</script>
@@ -37,6 +44,7 @@ export default {
:show-text="!isChildView"
:icon-classes="'gl-vertical-align-middle!'"
data-qa-selector="pipeline_commit_status"
+ @ciStatusBadgeClick="trackClick"
/>
<pipelines-timeago class="gl-mt-3" :pipeline="pipeline" />
</div>
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 53da98434b0..f6e46c090d3 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,8 +1,10 @@
<script>
import { GlTableLite, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
+import Tracking from '~/tracking';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import eventHub from '../../event_hub';
-import PipelineMiniGraph from './pipeline_mini_graph.vue';
+import { TRACKING_CATEGORIES } from '../../constants';
import PipelineOperations from './pipeline_operations.vue';
import PipelineStopModal from './pipeline_stop_modal.vue';
import PipelineTriggerer from './pipeline_triggerer.vue';
@@ -17,8 +19,6 @@ const DEFAULT_TH_CLASSES =
export default {
components: {
GlTableLite,
- LinkedPipelinesMiniList: () =>
- import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
PipelineMiniGraph,
PipelineOperations,
PipelinesStatusBadge,
@@ -70,6 +70,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [Tracking.mixin()],
props: {
pipelines: {
type: Array,
@@ -126,6 +127,9 @@ export default {
onPipelineActionRequestComplete() {
eventHub.$emit('refreshPipelinesTable');
},
+ trackPipelineMiniGraph() {
+ this.track('click_minigraph', { label: TRACKING_CATEGORIES.table });
+ },
},
TBODY_TR_ATTR: {
'data-testid': 'pipeline-table-row',
@@ -169,29 +173,15 @@ export default {
</template>
<template #cell(stages)="{ item }">
- <div class="stage-cell">
- <!-- This empty div should be removed, see https://gitlab.com/gitlab-org/gitlab/-/issues/323488 -->
- <div></div>
- <linked-pipelines-mini-list
- v-if="item.triggered_by"
- :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
- item.triggered_by,
- ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- data-testid="mini-graph-upstream"
- />
- <pipeline-mini-graph
- v-if="item.details && item.details.stages && item.details.stages.length > 0"
- :stages="item.details.stages"
- :update-dropdown="updateGraphDropdown"
- @pipelineActionRequestComplete="onPipelineActionRequestComplete"
- />
- <linked-pipelines-mini-list
- v-if="item.triggered.length"
- :triggered="item.triggered"
- :pipeline-path="item.path"
- data-testid="mini-graph-downstream"
- />
- </div>
+ <pipeline-mini-graph
+ :downstream-pipelines="item.triggered"
+ :pipeline-path="item.path"
+ :stages="item.details.stages"
+ :update-dropdown="updateGraphDropdown"
+ :upstream-pipeline="item.triggered_by"
+ @pipelineActionRequestComplete="onPipelineActionRequestComplete"
+ @miniGraphStageClick="trackPipelineMiniGraph"
+ />
</template>
<template #cell(actions)="{ item }">
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 7b38f870cb6..327633dcb1a 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -110,4 +110,8 @@ export const DEFAULT_FIELDS = [
},
];
-export const performanceModalId = 'performanceInsightsModal';
+export const TRACKING_CATEGORIES = {
+ table: 'pipelines_table_component',
+ tabs: 'pipelines_filter_tabs',
+ search: 'pipelines_filtered_search',
+};
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql
deleted file mode 100644
index 25e990c8934..00000000000
--- a/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql
+++ /dev/null
@@ -1,28 +0,0 @@
-query getPerformanceInsightsData($fullPath: ID!, $iid: ID!) {
- project(fullPath: $fullPath) {
- id
- pipeline(iid: $iid) {
- id
- jobs {
- pageInfo {
- hasNextPage
- }
- nodes {
- id
- duration
- detailedStatus {
- id
- detailsPath
- }
- name
- stage {
- id
- name
- }
- startedAt
- queuedDuration
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
index 641ec7a3cf6..b0f875160d4 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
@@ -11,6 +11,7 @@ query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) {
}
nodes {
artifacts {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
nodes {
downloadPath
fileType
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
index 2fedd7e7a98..c9e60756407 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_header.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import pipelineHeader from './components/header_component.vue';
+import PipelineHeader from './components/header_component.vue';
Vue.use(VueApollo);
@@ -16,7 +16,7 @@ export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResou
new Vue({
el,
components: {
- pipelineHeader,
+ PipelineHeader,
},
apolloProvider,
provide: {
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index 7051d356089..508f188c229 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -20,6 +20,8 @@ export const createAppOptions = (selector, apolloProvider) => {
const {
canGenerateCodequalityReports,
codequalityReportDownloadPath,
+ codequalityBlobPath,
+ codequalityProjectPath,
downloadablePathForReportType,
exposeSecurityDashboard,
exposeLicenseScanningData,
@@ -40,9 +42,12 @@ export const createAppOptions = (selector, apolloProvider) => {
hasTestReport,
emptyStateImagePath,
artifactsExpiredImagePath,
+ isFullCodequalityReportAvailable,
testsCount,
} = dataset;
+ // TODO remove projectPath variable once https://gitlab.com/gitlab-org/gitlab/-/issues/371641 is resolved
+ const projectPath = fullPath;
const defaultTabValue = getPipelineDefaultTab(window.location.href);
return {
@@ -63,6 +68,10 @@ export const createAppOptions = (selector, apolloProvider) => {
provide: {
canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports),
codequalityReportDownloadPath,
+ codequalityBlobPath,
+ codequalityProjectPath,
+ isFullCodequalityReportAvailable: parseBoolean(isFullCodequalityReportAvailable),
+ projectPath,
defaultTabValue,
downloadablePathForReportType,
exposeSecurityDashboard: parseBoolean(exposeSecurityDashboard),
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 83e00b80426..588d15495ab 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -153,24 +153,3 @@ export const getPipelineDefaultTab = (url) => {
return null;
};
-
-export const calculateJobStats = (jobs, sortField) => {
- const jobNodes = [...jobs.nodes];
-
- const sorted = jobNodes.sort((a, b) => {
- return b[sortField] - a[sortField];
- });
-
- return sorted[0];
-};
-
-export const calculateSlowestFiveJobs = (jobs) => {
- const jobNodes = [...jobs.nodes];
- const limit = 5;
-
- return jobNodes
- .sort((a, b) => {
- return b.duration - a.duration;
- })
- .slice(0, limit);
-};
diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js
index f208280af27..2d31cf772e3 100644
--- a/app/assets/javascripts/profile/account/index.js
+++ b/app/assets/javascripts/profile/account/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
-import deleteAccountModal from './components/delete_account_modal.vue';
+import DeleteAccountModal from './components/delete_account_modal.vue';
import UpdateUsername from './components/update_username.vue';
export default () => {
@@ -27,7 +27,7 @@ export default () => {
new Vue({
el: deleteAccountModalEl,
components: {
- deleteAccountModal,
+ DeleteAccountModal,
},
mounted() {
deleteAccountButton.disabled = false;
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 064bcf8e4c4..af5beeb686c 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,11 +1,14 @@
import $ from 'jquery';
+import Vue from 'vue';
import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { parseRailsFormFields } from '~/lib/utils/forms';
import { Rails } from '~/lib/utils/rails_ujs';
import TimezoneDropdown, {
formatTimezone,
} from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown';
+import UserProfileSetStatusWrapper from '~/set_status_modal/user_profile_set_status_wrapper.vue';
export default class Profile {
constructor({ form } = {}) {
@@ -116,3 +119,24 @@ export default class Profile {
}
}
}
+
+export const initSetStatusForm = () => {
+ const el = document.getElementById('js-user-profile-set-status-form');
+
+ if (!el) {
+ return null;
+ }
+
+ const fields = parseRailsFormFields(el);
+
+ return new Vue({
+ el,
+ name: 'UserProfileStatusForm',
+ provide: {
+ fields,
+ },
+ render(h) {
+ return h(UserProfileSetStatusWrapper);
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
index 1cdf26b76b7..4505dd1f85c 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
@@ -2,11 +2,11 @@
import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
-import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
import {
getQueryHeaders,
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import { formatStages } from '../utils';
import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql';
import getPipelineStagesQuery from '../graphql/queries/get_pipeline_stages.query.graphql';
@@ -21,8 +21,6 @@ export default {
components: {
GlLoadingIcon,
PipelineMiniGraph,
- LinkedPipelinesMiniList: () =>
- import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
},
inject: {
fullPath: {
@@ -92,12 +90,12 @@ export default {
};
},
computed: {
- hasDownstream() {
- return this.pipeline?.downstream?.nodes.length > 0;
- },
downstreamPipelines() {
return this.pipeline?.downstream?.nodes;
},
+ pipelinePath() {
+ return this.pipeline?.path ?? '';
+ },
upstreamPipeline() {
return this.pipeline?.upstream;
},
@@ -128,23 +126,13 @@ export default {
<template>
<div class="gl-pt-2">
<gl-loading-icon v-if="$apollo.queries.pipeline.loading" />
- <div v-else class="gl-align-items-center gl-display-flex">
- <linked-pipelines-mini-list
- v-if="upstreamPipeline"
- :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
- upstreamPipeline,
- ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- data-testid="commit-box-mini-graph-upstream"
- />
-
- <pipeline-mini-graph :stages="formattedStages" data-testid="commit-box-mini-graph" />
-
- <linked-pipelines-mini-list
- v-if="hasDownstream"
- :triggered="downstreamPipelines"
- :pipeline-path="pipeline.path"
- data-testid="commit-box-mini-graph-downstream"
- />
- </div>
+ <pipeline-mini-graph
+ v-else
+ data-testid="commit-box-pipeline-mini-graph"
+ :downstream-pipelines="downstreamPipelines"
+ :pipeline-path="pipelinePath"
+ :stages="formattedStages"
+ :upstream-pipeline="upstreamPipeline"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
index ecd2288eb2f..06d96ef7bef 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
@@ -1,7 +1,7 @@
<script>
import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
-import dateFormat from 'dateformat';
+import dateFormat from '~/lib/dateformat';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue';
diff --git a/app/assets/javascripts/projects/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js
index b8ac17a01f2..84b8936c17f 100644
--- a/app/assets/javascripts/projects/project_visibility.js
+++ b/app/assets/javascripts/projects/project_visibility.js
@@ -1,13 +1,7 @@
import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
import eventHub from '~/projects/new/event_hub';
-
-// Values are from lib/gitlab/visibility_level.rb
-const visibilityLevel = {
- private: 0,
- internal: 10,
- public: 20,
-};
+import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants';
function setVisibilityOptions({ name, visibility, showPath, editPath }) {
document.querySelectorAll('.visibility-level-setting .gl-form-radio').forEach((option) => {
@@ -19,13 +13,14 @@ function setVisibilityOptions({ name, visibility, showPath, editPath }) {
const optionInput = option.querySelector('input[type=radio]');
const optionValue = optionInput ? parseInt(optionInput.value, 10) : 0;
- if (visibilityLevel[visibility] < optionValue) {
+ if (VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility] < optionValue) {
option.classList.add('disabled');
optionInput.disabled = true;
const reason = option.querySelector('.option-disabled-reason');
if (reason) {
const optionTitle = option.querySelector('.js-visibility-level-radio span');
const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : '';
+ // eslint-disable-next-line no-unsanitized/property
reason.innerHTML = sprintf(
__(
'This project cannot be %{visibilityLevel} because the visibility of %{openShowLink}%{name}%{closeShowLink} is %{visibility}. To make this project %{visibilityLevel}, you must first %{openEditLink}change the visibility%{closeEditLink} of the parent group.',
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
index ada951f6867..e8eaf0a70b2 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -1,16 +1,58 @@
<script>
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
+import createFlash from '~/flash';
+import branchRulesQuery from './graphql/queries/branch_rules.query.graphql';
+import BranchRule from './components/branch_rule.vue';
+
+export const i18n = {
+ queryError: s__(
+ 'ProtectedBranch|An error occurred while loading branch rules. Please try again.',
+ ),
+ emptyState: s__(
+ 'ProtectedBranch|Protected branches, merge request approvals, and status checks will appear here once configured.',
+ ),
+};
export default {
name: 'BranchRules',
- i18n: { heading: __('Branch') },
+ i18n,
+ components: {
+ BranchRule,
+ },
+ apollo: {
+ branchRules: {
+ query: branchRulesQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return data.project?.branchRules?.nodes || [];
+ },
+ error() {
+ createFlash({ message: this.$options.i18n.queryError });
+ },
+ },
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ branchRules: [],
+ };
+ },
};
</script>
<template>
- <div>
- <strong>{{ $options.i18n.heading }}</strong>
+ <div class="settings-content">
+ <branch-rule v-for="rule in branchRules" :key="rule.name" :name="rule.name" />
- <!-- TODO - List branch rules (https://gitlab.com/gitlab-org/gitlab/-/issues/362217) -->
+ <span v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</span>
</div>
</template>
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
new file mode 100644
index 00000000000..68750318029
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export const i18n = {
+ defaultLabel: s__('BranchRules|default'),
+ protectedLabel: s__('BranchRules|protected'),
+};
+
+export default {
+ name: 'BranchRule',
+ i18n,
+ components: {
+ GlBadge,
+ },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ isDefault: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isProtected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ approvalDetails: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ hasApprovalDetails() {
+ return this.approvalDetails && this.approvalDetails.length;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-border-b gl-pt-5 gl-pb-5">
+ <strong class="gl-font-monospace">{{ name }}</strong>
+
+ <gl-badge v-if="isDefault" variant="info" size="sm" class="gl-ml-2">{{
+ $options.i18n.defaultLabel
+ }}</gl-badge>
+
+ <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{
+ $options.i18n.protectedLabel
+ }}</gl-badge>
+
+ <ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500">
+ <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql
new file mode 100644
index 00000000000..104a0c25a80
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql
@@ -0,0 +1,10 @@
+query getBranchRules($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ id
+ branchRules {
+ nodes {
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
index abe0b93081e..35322e2e466 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
@@ -1,13 +1,28 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import BranchRulesApp from '~/projects/settings/repository/branch_rules/app.vue';
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
export default function mountBranchRules(el) {
if (!el) return null;
+ const { projectPath } = el.dataset;
+
return new Vue({
el,
+ apolloProvider,
render(createElement) {
- return createElement(BranchRulesApp);
+ return createElement(BranchRulesApp, {
+ props: {
+ projectPath,
+ },
+ });
},
});
}
diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
index 9c8de9bef2d..3d553e71f71 100644
--- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
+++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
@@ -2,7 +2,7 @@
import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui';
import { s__ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
-import searchProjectTopics from '../queries/project_topics_search.query.graphql';
+import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
export default {
components: {
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
index 14c8c53dd19..71ff3e892b1 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -1,12 +1,18 @@
<script>
-import { GlAlert, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlAlert, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { __, sprintf } from '~/locale';
import ServiceDeskSetting from './service_desk_setting.vue';
export default {
+ customEmailHelpPath: helpPagePath('/user/project/service_desk.html', {
+ anchor: 'using-a-custom-email-address',
+ }),
components: {
GlAlert,
+ GlSprintf,
+ GlLink,
ServiceDeskSetting,
},
directives: {
@@ -43,6 +49,9 @@ export default {
templates: {
default: [],
},
+ publicProject: {
+ default: false,
+ },
},
data() {
return {
@@ -127,6 +136,27 @@ export default {
<template>
<div>
+ <gl-alert
+ v-if="publicProject && isEnabled"
+ class="mb-3"
+ variant="warning"
+ data-testid="public-project-alert"
+ :dismissible="false"
+ >
+ <gl-sprintf
+ :message="
+ __(
+ 'This project is public. Non-members can guess the Service Desk email address, because it contains the group and project name. %{linkStart}How do I create a custom email address?%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="$options.customEmailHelpPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss">
<span v-safe-html="alertMessage"></span>
</gl-alert>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index 8a9a0b541f3..452e7a4fd21 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -176,7 +176,7 @@ export default {
</template>
</gl-form-input-group>
<template v-if="email && hasCustomEmail" #description>
- <span class="gl-mt-2 d-inline-block">
+ <span class="gl-mt-2 gl-display-inline-block">
<gl-sprintf :message="__('Emails sent to %{email} are also supported.')">
<template #email>
<code>{{ incomingEmail }}</code>
@@ -190,7 +190,11 @@ export default {
</template>
</gl-form-group>
- <gl-form-group :label="__('Email address suffix')" :state="!projectKeyError">
+ <gl-form-group
+ :label="__('Email address suffix')"
+ :state="!projectKeyError"
+ data-testid="suffix-form-group"
+ >
<gl-form-input
v-if="hasProjectKeySupport"
id="service-desk-project-suffix"
@@ -216,22 +220,24 @@ export default {
</gl-sprintf>
</template>
<template v-else #description>
- <gl-sprintf
- :message="
- __(
- 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}',
- )
- "
- >
- <template #link="{ content }">
- <gl-link
- :href="customEmailAddressHelpUrl"
- target="_blank"
- class="gl-text-blue-600 font-size-inherit"
- >{{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
+ <span class="gl-text-gray-900">
+ <gl-sprintf
+ :message="
+ __(
+ 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ :href="customEmailAddressHelpUrl"
+ target="_blank"
+ class="gl-text-blue-600 font-size-inherit"
+ >{{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
</template>
<template v-if="hasProjectKeySupport && projectKeyError" #invalid-feedback>
@@ -266,7 +272,27 @@ export default {
/>
<template v-if="hasProjectKeySupport" #description>
- {{ __('Emails sent from Service Desk have this name.') }}
+ {{ __('Name to be used as the sender for emails from Service Desk.') }}
+ </template>
+ <template v-else #description>
+ <span class="gl-text-gray-900">
+ <gl-sprintf
+ :message="
+ __(
+ 'To add display name, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ :href="customEmailAddressHelpUrl"
+ target="_blank"
+ class="gl-text-blue-600 font-size-inherit"
+ >{{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
</template>
</gl-form-group>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue
index bdd9f940d79..315f0743b53 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue
@@ -100,7 +100,7 @@ export default {
<gl-dropdown-item
v-for="template in item"
:key="template.key"
- :is-check-item="true"
+ is-check-item
:is-checked="
template.project_id === selectedFileTemplateProjectId &&
template.name === selectedTemplate
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
index e14cdee17ce..26435a5fac9 100644
--- a/app/assets/javascripts/projects/settings_service_desk/index.js
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -20,6 +20,7 @@ export default () => {
selectedTemplate,
selectedFileTemplateProjectId,
templates,
+ publicProject,
} = el.dataset;
return new Vue({
@@ -35,6 +36,7 @@ export default () => {
selectedTemplate,
selectedFileTemplateProjectId: parseInt(selectedFileTemplateProjectId, 10) || null,
templates: JSON.parse(templates),
+ publicProject: parseBoolean(publicProject),
},
render: (createElement) => createElement(ServiceDeskRoot),
});
diff --git a/app/assets/javascripts/projects/star.js b/app/assets/javascripts/projects/star.js
index 5bbace11b15..e063064663b 100644
--- a/app/assets/javascripts/projects/star.js
+++ b/app/assets/javascripts/projects/star.js
@@ -22,11 +22,14 @@ export default class Star {
starSpan.classList.remove('starred');
starSpan.textContent = s__('StarProject|Star');
starIcon.remove();
+ // eslint-disable-next-line no-unsanitized/method
starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star-o', iconClasses));
} else {
starSpan.classList.add('starred');
starSpan.textContent = __('Unstar');
starIcon.remove();
+
+ // eslint-disable-next-line no-unsanitized/method
starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star', iconClasses));
}
})
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index a79da00de43..6b14ebadacc 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -4,7 +4,7 @@ import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import Poll from '~/lib/utils/poll';
import { __, s__, sprintf } from '~/locale';
-import ciIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
import CommitPipelineService from '../services/commit_pipeline_service';
export default {
@@ -12,7 +12,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- ciIcon,
+ CiIcon,
GlLoadingIcon,
},
props: {
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 270d4632a54..09ecad2d90e 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -7,14 +7,14 @@ import {
inputPlaceholderTextMap,
issuableTypesMap,
} from '../constants';
-import issueToken from './issue_token.vue';
+import IssueToken from './issue_token.vue';
const SPACE_FACTOR = 1;
export default {
name: 'RelatedIssuableInput',
components: {
- issueToken,
+ IssueToken,
},
props: {
inputId: {
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 5b4a6d1fe0d..53f2dbbbbd7 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -3,7 +3,6 @@ import { GlLink, GlIcon, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import {
issuableIconMap,
- issuableQaClassMap,
linkedIssueTypesMap,
linkedIssueTypesTextMap,
issuablesBlockHeaderTextMap,
@@ -142,9 +141,6 @@ export default {
issuableTypeIcon() {
return issuableIconMap[this.issuableType];
},
- qaClass() {
- return issuableQaClassMap[this.issuableType];
- },
toggleIcon() {
return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
},
@@ -166,11 +162,15 @@ export default {
</script>
<template>
- <div id="related-issues" class="related-issues-block gl-mt-5">
- <div class="card card-slim gl-overflow-hidden">
+ <div id="related-issues" class="related-issues-block">
+ <div class="card card-slim gl-overflow-hidden gl-mt-5 gl-mb-0">
<div
- :class="{ 'panel-empty-heading border-bottom-0': !hasBody, 'gl-border-b-0': !isOpen }"
- class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-py-3 gl-px-5 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ :class="{
+ 'panel-empty-heading border-bottom-0': !hasBody,
+ 'gl-border-b-1': isOpen,
+ 'gl-border-b-0': !isOpen,
+ }"
+ class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-py-3 gl-px-5 gl-bg-gray-10 gl-border-b-solid gl-border-b-gray-100"
>
<h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1">
<gl-link
@@ -205,7 +205,6 @@ export default {
data-qa-selector="related_issues_plus_button"
data-testid="related-issues-plus-button"
:aria-label="addIssuableButtonText"
- :class="qaClass"
class="gl-ml-3"
@click="addButtonClick"
>
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 cad5037d7e4..ae40232df6f 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -40,7 +40,7 @@ import RelatedIssuesBlock from './related_issues_block.vue';
export default {
name: 'RelatedIssuesRoot',
components: {
- relatedIssuesBlock: RelatedIssuesBlock,
+ RelatedIssuesBlock,
},
props: {
endpoint: {
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index 23ea93cd258..4eb054ccb5c 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -99,15 +99,6 @@ export const issuableIconMap = {
[issuableTypesMap.EPIC]: 'epic',
};
-/**
- * These are used to map issuableType to the correct QA class.
- * Since these are never used for any display purposes, don't wrap
- * them inside i18n functions.
- */
-export const issuableQaClassMap = {
- [issuableTypesMap.EPIC]: 'qa-add-epics-button',
-};
-
export const PathIdSeparator = {
Epic: '&',
Issue: '#',
diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue
index 78831ceefe9..6d415471b14 100644
--- a/app/assets/javascripts/releases/components/evidence_block.vue
+++ b/app/assets/javascripts/releases/components/evidence_block.vue
@@ -1,6 +1,6 @@
<script>
import { GlLink, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import dateFormat from 'dateformat';
+import dateFormat from '~/lib/dateformat';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
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 a71a8125d65..669e5928143 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -16,6 +16,8 @@ import {
import * as types from './mutation_types';
+class GraphQLError extends Error {}
+
export const initializeRelease = ({ commit, dispatch, state }) => {
if (state.isExistingRelease) {
// When editing an existing release,
@@ -110,35 +112,35 @@ export const saveRelease = ({ commit, dispatch, state }) => {
*
* @param {Object} gqlResponse The response object returned by the GraphQL client
* @param {String} mutationName The name of the mutation that was executed
- * @param {String} messageIfError An message to build into the error object if something went wrong
*/
-const checkForErrorsAsData = (gqlResponse, mutationName, messageIfError) => {
+const checkForErrorsAsData = (gqlResponse, mutationName) => {
const allErrors = gqlResponse.data[mutationName].errors;
if (allErrors.length > 0) {
- const allErrorMessages = JSON.stringify(allErrors);
- throw new Error(`${messageIfError}: ${allErrorMessages}`);
+ throw new GraphQLError(allErrors[0]);
}
};
-export const createRelease = async ({ commit, dispatch, state, getters }) => {
+export const createRelease = async ({ commit, dispatch, getters }) => {
try {
const response = await gqClient.mutate({
mutation: createReleaseMutation,
variables: getters.releaseCreateMutatationVariables,
});
- checkForErrorsAsData(
- response,
- 'releaseCreate',
- `Something went wrong while creating a new release with projectPath "${state.projectPath}" and tagName "${state.release.tagName}"`,
- );
+ checkForErrorsAsData(response, 'releaseCreate');
dispatch('receiveSaveReleaseSuccess', response.data.releaseCreate.release.links.selfUrl);
} catch (error) {
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
- createFlash({
- message: s__('Release|Something went wrong while creating a new release.'),
- });
+ if (error instanceof GraphQLError) {
+ createFlash({
+ message: error.message,
+ });
+ } else {
+ createFlash({
+ message: s__('Release|Something went wrong while creating a new release.'),
+ });
+ }
}
};
@@ -146,7 +148,7 @@ export const createRelease = async ({ commit, dispatch, state, getters }) => {
* Deletes a single release link.
* Throws an error if any network or validation errors occur.
*/
-const deleteReleaseLinks = async ({ state, id }) => {
+const deleteReleaseLinks = async ({ id }) => {
const deleteResponse = await gqClient.mutate({
mutation: deleteReleaseAssetLinkMutation,
variables: {
@@ -154,11 +156,7 @@ const deleteReleaseLinks = async ({ state, id }) => {
},
});
- checkForErrorsAsData(
- deleteResponse,
- 'releaseAssetLinkDelete',
- `Something went wrong while deleting release asset link for release with projectPath "${state.projectPath}", tagName "${state.tagName}", and link id "${id}"`,
- );
+ checkForErrorsAsData(deleteResponse, 'releaseAssetLinkDelete');
};
/**
@@ -180,11 +178,7 @@ const createReleaseLink = async ({ state, link }) => {
},
});
- checkForErrorsAsData(
- createResponse,
- 'releaseAssetLinkCreate',
- `Something went wrong while creating a release asset link for release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`,
- );
+ checkForErrorsAsData(createResponse, 'releaseAssetLinkCreate');
};
export const updateRelease = async ({ commit, dispatch, state, getters }) => {
@@ -210,11 +204,7 @@ export const updateRelease = async ({ commit, dispatch, state, getters }) => {
variables: getters.releaseUpdateMutatationVariables,
});
- checkForErrorsAsData(
- updateReleaseResponse,
- 'releaseUpdate',
- `Something went wrong while updating release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`,
- );
+ checkForErrorsAsData(updateReleaseResponse, 'releaseUpdate');
// Delete all links currently associated with this Release
await Promise.all(
@@ -266,7 +256,7 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => {
mutation: deleteReleaseMutation,
variables: getters.releaseDeleteMutationVariables,
})
- .then((response) => checkForErrorsAsData(response, 'releaseDelete', ''))
+ .then((response) => checkForErrorsAsData(response, 'releaseDelete'))
.then(() => {
window.sessionStorage.setItem(
deleteReleaseSessionKey(state.projectPath),
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 62d6bd42d51..ccca9ca8250 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -130,7 +130,7 @@ export const releaseUpdateMutatationVariables = (state, getters) => {
projectPath: state.projectPath,
tagName: state.release.tagName,
name,
- releasedAt: state.release.releasedAt,
+ releasedAt: getters.releasedAtChanged ? state.release.releasedAt : null,
description: state.includeTagNotes
? getters.formattedReleaseNotes
: state.release.description,
@@ -167,3 +167,6 @@ export const formattedReleaseNotes = ({ includeTagNotes, release: { description
includeTagNotes && tagNotes
? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`
: description;
+
+export const releasedAtChanged = ({ originalReleasedAt, release }) =>
+ originalReleasedAt !== release.releasedAt;
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
index ea794f91f66..34361f84a5a 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
@@ -14,7 +14,7 @@ export default {
description: '',
milestones: [],
groupMilestones: [],
- releasedAt: new Date(),
+ releasedAt: state.originalReleasedAt,
assets: {
links: [],
},
@@ -29,6 +29,7 @@ export default {
state.isFetchingRelease = false;
state.release = data;
state.originalRelease = Object.freeze(cloneDeep(state.release));
+ state.originalReleasedAt = state.originalRelease.releasedAt;
},
[types.RECEIVE_RELEASE_ERROR](state, error) {
state.fetchError = error;
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
index cb447cf9aaf..11a2f9df59b 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
@@ -61,4 +61,5 @@ export default ({
tagNotes: '',
includeTagNotes: false,
existingRelease: null,
+ originalReleasedAt: new Date(),
});
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 78572f11f6f..902077ba3e4 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -13,9 +13,10 @@ 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 LineHighlighter from '~/blob/line_highlighter';
+import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql';
import addBlameLink from '~/blob/blob_blame_link';
+import projectInfoQuery from '../queries/project_info.query.graphql';
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, LEGACY_FILE_TYPES } from '../constants';
@@ -41,6 +42,21 @@ export default {
},
},
apollo: {
+ projectInfo: {
+ query: projectInfoQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ error() {
+ this.displayError();
+ },
+ update({ project }) {
+ this.pathLocks = project.pathLocks || DEFAULT_BLOB_INFO.pathLocks;
+ this.userPermissions = project.userPermissions;
+ },
+ },
gitpodEnabled: {
query: applicationInfoQuery,
error() {
@@ -121,6 +137,8 @@ export default {
gitpodEnabled: DEFAULT_BLOB_INFO.gitpodEnabled,
currentUser: DEFAULT_BLOB_INFO.currentUser,
useFallback: false,
+ pathLocks: DEFAULT_BLOB_INFO.pathLocks,
+ userPermissions: DEFAULT_BLOB_INFO.userPermissions,
};
},
computed: {
@@ -163,7 +181,7 @@ export default {
);
},
canLock() {
- const { pushCode, downloadCode } = this.project.userPermissions;
+ const { pushCode, downloadCode } = this.userPermissions;
const currentUsername = window.gon?.current_username;
if (this.pathLockedByUser && this.pathLockedByUser.username !== currentUsername) {
@@ -173,12 +191,12 @@ export default {
return pushCode && downloadCode;
},
pathLockedByUser() {
- const pathLock = this.project?.pathLocks?.nodes.find((node) => node.path === this.path);
+ const pathLock = this.pathLocks?.nodes.find((node) => node.path === this.path);
return pathLock ? pathLock.user : null;
},
showForkSuggestion() {
- const { createMergeRequestIn, forkProject } = this.project.userPermissions;
+ const { createMergeRequestIn, forkProject } = this.userPermissions;
const { canModifyBlob } = this.blobInfo;
return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject;
@@ -338,7 +356,7 @@ export default {
:name="blobInfo.name"
:replace-path="blobInfo.replacePath"
:delete-path="blobInfo.webPath"
- :can-push-code="project.userPermissions.pushCode"
+ :can-push-code="userPermissions.pushCode"
:can-push-to-branch="blobInfo.canCurrentUserPushToBranch"
:empty-repo="project.repository.empty"
:project-path="projectPath"
diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/blob_controls.vue
index 3223ed92fe2..fb1227f0df9 100644
--- a/app/assets/javascripts/repository/components/blob_controls.vue
+++ b/app/assets/javascripts/repository/components/blob_controls.vue
@@ -90,7 +90,7 @@ export default {
</script>
<template>
- <div v-if="showBlobControls">
+ <div v-if="showBlobControls" class="gl-display-flex gl-gap-3">
<gl-button data-testid="find" :href="blobInfo.findFilePath" :class="$options.buttonClassList">
{{ $options.i18n.findFile }}
</gl-button>
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 20888db80a9..46dee9db69a 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -191,7 +191,7 @@ export default {
href: `${this.newBlobPath}/${
this.currentPath ? encodeURIComponent(this.currentPath) : ''
}`,
- class: 'qa-new-file-option',
+ 'data-qa-selector': 'new_file_menu_item',
},
text: __('New file'),
},
@@ -300,7 +300,11 @@ export default {
</router-link>
</li>
<li v-if="renderAddToTreeDropdown" class="breadcrumb-item">
- <gl-dropdown toggle-class="add-to-tree qa-add-to-tree gl-ml-2">
+ <gl-dropdown
+ toggle-class="add-to-tree gl-ml-2"
+ data-testid="add-to-tree"
+ data-qa-selector="add_to_tree_dropdown"
+ >
<template #button-content>
<span class="sr-only">{{ __('Add to tree') }}</span>
<gl-icon name="plus" :size="16" class="float-left" />
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 7f408485326..22fe3fe440e 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -135,7 +135,7 @@ export default {
<div
class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0"
>
- <div class="commit-content qa-commit-content">
+ <div class="commit-content" data-qa-selector="commit_content">
<gl-link
v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml"
:href="commit.webPath"
@@ -148,6 +148,7 @@ export default {
:class="{ open: showDescription }"
:title="__('Toggle commit description')"
:aria-label="__('Toggle commit description')"
+ :selected="showDescription"
class="text-expander gl-vertical-align-bottom!"
icon="ellipsis_h"
@click="toggleShowDescription"
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 1f6b5e98122..99eb167172b 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -2,6 +2,7 @@
import { GlSkeletonLoader, GlButton } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintf, __ } from '~/locale';
+import { joinPaths } from '~/lib/utils/url_utility';
import getRefMixin from '../../mixins/get_ref';
import projectPathQuery from '../../queries/project_path.query.graphql';
import TableHeader from './header.vue';
@@ -108,7 +109,9 @@ export default {
return {};
}
- return this.commits.find((commitEntry) => commitEntry.fileName === fileName);
+ return this.commits.find(
+ (commitEntry) => commitEntry.filePath === joinPaths(this.path, fileName),
+ );
},
},
};
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 99b7395d6e7..c8cd64b5311 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -17,8 +17,8 @@ import { TREE_PAGE_SIZE, ROW_APPEAR_DELAY } from '~/repository/constants';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql';
import getRefMixin from '../../mixins/get_ref';
-import blobInfoQuery from '../../queries/blob_info.query.graphql';
import commitQuery from '../../queries/commit.query.graphql';
export default {
@@ -244,7 +244,7 @@ export default {
/><span class="position-relative">{{ fullPath }}</span>
</component>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
- <gl-badge v-if="lfsOid" variant="muted" size="sm" class="ml-1" data-qa-selector="label-lfs"
+ <gl-badge v-if="lfsOid" variant="muted" size="sm" class="ml-1" data-testid="label-lfs"
>LFS</gl-badge
>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js
index 9345a8406e3..a5bcd9e6b5e 100644
--- a/app/assets/javascripts/repository/log_tree.js
+++ b/app/assets/javascripts/repository/log_tree.js
@@ -1,6 +1,7 @@
import produce from 'immer';
import { normalizeData } from 'ee_else_ce/repository/utils/commit';
import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
import commitsQuery from './queries/commits.query.graphql';
import projectPathQuery from './queries/project_path.query.graphql';
import refQuery from './queries/ref.query.graphql';
@@ -16,7 +17,7 @@ function setNextOffset(offset) {
}
export function resolveCommit(commits, path, { resolve, entry }) {
- const commit = commits.find((c) => c.filePath === `${path}/${entry.name}`);
+ const commit = commits.find((c) => c.filePath === joinPaths(path, entry.name));
if (commit) {
resolve(commit);
diff --git a/app/assets/javascripts/repository/queries/project_info.query.graphql b/app/assets/javascripts/repository/queries/project_info.query.graphql
new file mode 100644
index 00000000000..7a380d25bb1
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/project_info.query.graphql
@@ -0,0 +1,14 @@
+#import "ee_else_ce/repository/queries/path_locks.fragment.graphql"
+
+query getProjectInfo($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ id
+ userPermissions {
+ pushCode
+ downloadCode
+ createMergeRequestIn
+ forkProject
+ }
+ ...ProjectPathLocksFragment
+ }
+}
diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js
index 878b4fdd71a..247e30d20fc 100644
--- a/app/assets/javascripts/repository/utils/commit.js
+++ b/app/assets/javascripts/repository/utils/commit.js
@@ -1,3 +1,5 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+
export function normalizeData(data, path, extra = () => {}) {
return data.map((d) => ({
sha: d.commit.id,
@@ -6,7 +8,7 @@ export function normalizeData(data, path, extra = () => {}) {
committedDate: d.commit.committed_date,
commitPath: d.commit_path,
fileName: d.file_name,
- filePath: `${path}/${d.file_name}`,
+ filePath: joinPaths(path, d.file_name),
__typename: 'LogTreeCommit',
...extra(d),
}));
diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js
index 0b6c5063129..7b5babdd3a6 100644
--- a/app/assets/javascripts/rest_api.js
+++ b/app/assets/javascripts/rest_api.js
@@ -6,6 +6,7 @@ export * from './api/bulk_imports_api';
export * from './api/namespaces_api';
export * from './api/tags_api';
export * from './api/alert_management_alerts_api';
+export * from './api/harbor_registry';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.
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 777a332333d..f5620876783 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -15,6 +15,7 @@ import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.gr
import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
+import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerBulkDelete from '../components/runner_bulk_delete.vue';
import RunnerBulkDeleteCheckbox from '../components/runner_bulk_delete_checkbox.vue';
@@ -37,6 +38,7 @@ export default {
components: {
GlLink,
RegistrationDropdown,
+ RunnerStackedLayoutBanner,
RunnerFilteredSearchBar,
RunnerBulkDelete,
RunnerBulkDeleteCheckbox,
@@ -169,6 +171,8 @@ export default {
</script>
<template>
<div>
+ <runner-stacked-layout-banner />
+
<div
class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
>
diff --git a/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue
new file mode 100644
index 00000000000..e5d49eb7c8e
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import RunnerName from '../runner_name.vue';
+import RunnerTags from '../runner_tags.vue';
+import RunnerTypeBadge from '../runner_type_badge.vue';
+
+import { formatJobCount } from '../../utils';
+import {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ I18N_VERSION_LABEL,
+ I18N_LAST_CONTACT_LABEL,
+ I18N_CREATED_AT_LABEL,
+} from '../../constants';
+import RunnerSummaryField from './runner_summary_field.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlSprintf,
+ TimeAgo,
+ RunnerSummaryField,
+ RunnerName,
+ RunnerTags,
+ RunnerTypeBadge,
+ RunnerUpgradeStatusIcon: () =>
+ import('ee_component/runner/components/runner_upgrade_status_icon.vue'),
+ TooltipOnTruncate,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ jobCount() {
+ return formatJobCount(this.runner.jobCount);
+ },
+ },
+ i18n: {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ I18N_VERSION_LABEL,
+ I18N_LAST_CONTACT_LABEL,
+ I18N_CREATED_AT_LABEL,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div>
+ <slot :runner="runner" name="runner-name">
+ <runner-name :runner="runner" />
+ </slot>
+ <gl-icon
+ v-if="runner.locked"
+ v-gl-tooltip
+ :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION"
+ name="lock"
+ />
+ <runner-type-badge :type="runner.runnerType" size="sm" class="gl-vertical-align-middle" />
+ </div>
+
+ <div class="gl-ml-auto gl-display-inline-flex gl-max-w-full gl-py-2">
+ <div class="gl-flex-shrink-0">
+ <runner-upgrade-status-icon :runner="runner" />
+ <gl-sprintf v-if="runner.version" :message="$options.i18n.I18N_VERSION_LABEL">
+ <template #version>{{ runner.version }}</template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div>
+ <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description">
+ {{ runner.description }}
+ </tooltip-on-truncate>
+ </div>
+
+ <div>
+ <runner-summary-field icon="clock">
+ <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" />
+ <template v-else>{{ __('Never') }}</template>
+ </template>
+ </gl-sprintf>
+ </runner-summary-field>
+
+ <runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')">
+ {{ runner.ipAddress }}
+ </runner-summary-field>
+
+ <runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')">
+ {{ jobCount }}
+ </runner-summary-field>
+
+ <runner-summary-field icon="calendar">
+ <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </runner-summary-field>
+ </div>
+
+ <runner-tags class="gl-display-block gl-pt-2" :tag-list="runner.tagList" size="sm" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
index a48db9f8ac8..eb98d4ae2fb 100644
--- a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
@@ -32,17 +32,14 @@ export default {
<div>
<runner-status-badge
:runner="runner"
- size="sm"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
<runner-upgrade-status-badge
:runner="runner"
- size="sm"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
<runner-paused-badge
v-if="paused"
- size="sm"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
</div>
diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
deleted file mode 100644
index 1cd098d6713..00000000000
--- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import RunnerName from '../runner_name.vue';
-import RunnerTypeBadge from '../runner_type_badge.vue';
-
-import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../../constants';
-
-export default {
- components: {
- GlIcon,
- TooltipOnTruncate,
- RunnerName,
- RunnerTypeBadge,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- runner: {
- type: Object,
- required: true,
- },
- },
- computed: {
- runnerType() {
- return this.runner.runnerType;
- },
- locked() {
- return this.runner.locked;
- },
- description() {
- return this.runner.description;
- },
- ipAddress() {
- return this.runner.ipAddress;
- },
- },
- i18n: {
- I18N_LOCKED_RUNNER_DESCRIPTION,
- },
-};
-</script>
-
-<template>
- <div>
- <slot :runner="runner" name="runner-name">
- <runner-name :runner="runner" />
- </slot>
-
- <runner-type-badge :type="runnerType" size="sm" />
- <gl-icon
- v-if="locked"
- v-gl-tooltip
- :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION"
- name="lock"
- />
- <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="description">
- {{ description }}
- </tooltip-on-truncate>
- <tooltip-on-truncate
- v-if="ipAddress"
- class="gl-display-block gl-text-truncate"
- :title="ipAddress"
- >
- <span class="gl-md-display-none gl-lg-display-inline">{{ __('IP Address') }}</span>
- <strong>{{ ipAddress }}</strong>
- </tooltip-on-truncate>
- </div>
-</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/runner/components/cells/runner_summary_field.vue
new file mode 100644
index 00000000000..1bbbd55089a
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/runner_summary_field.vue
@@ -0,0 +1,33 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ icon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltip: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2">
+ <gl-icon v-if="icon" :name="icon" />
+ <!-- display tooltip as a label for screen readers -->
+ <span class="gl-sr-only">{{ tooltip }}</span>
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue
index 584f77b7648..c260670b517 100644
--- a/app/assets/javascripts/runner/components/runner_detail.vue
+++ b/app/assets/javascripts/runner/components/runner_detail.vue
@@ -21,7 +21,8 @@ export default {
props: {
label: {
type: String,
- required: true,
+ default: null,
+ required: false,
},
value: {
type: String,
@@ -39,7 +40,11 @@ export default {
<template>
<div class="gl-display-contents">
- <dt class="gl-mb-5 gl-mr-6 gl-max-w-26">{{ label }}</dt>
+ <dt class="gl-mb-5 gl-mr-6 gl-max-w-26">
+ <template v-if="label || $scopedSlots.label">
+ <slot name="label">{{ label }}</slot>
+ </template>
+ </dt>
<dd class="gl-mb-5">
<template v-if="value || $scopedSlots.value">
<slot name="value">{{ value }}</slot>
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue
index d5222f39b81..79f934764c6 100644
--- a/app/assets/javascripts/runner/components/runner_details.vue
+++ b/app/assets/javascripts/runner/components/runner_details.vue
@@ -1,7 +1,10 @@
<script>
-import { GlIntersperse } from '@gitlab/ui';
+import { GlIntersperse, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants';
import RunnerDetail from './runner_detail.vue';
@@ -12,6 +15,8 @@ import RunnerTags from './runner_tags.vue';
export default {
components: {
GlIntersperse,
+ GlLink,
+ HelpPopover,
RunnerDetail,
RunnerMaintenanceNoteDetail: () =>
import('ee_component/runner/components/runner_maintenance_note_detail.vue'),
@@ -24,6 +29,7 @@ export default {
RunnerTags,
TimeAgo,
},
+ mixins: [glFeatureFlagMixin()],
props: {
runner: {
type: Object,
@@ -60,6 +66,16 @@ export default {
isProjectRunner() {
return this.runner?.runnerType === PROJECT_TYPE;
},
+ tokenExpirationHelpPopoverOptions() {
+ return {
+ title: s__('Runners|Runner authentication token expiration'),
+ };
+ },
+ tokenExpirationHelpUrl() {
+ return helpPagePath('ci/runners/configure_runners', {
+ anchor: 'authentication-token-security',
+ });
+ },
},
ACCESS_LEVEL_REF_PROTECTED,
};
@@ -101,6 +117,34 @@ export default {
</template>
</runner-detail>
<runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" />
+ <runner-detail
+ v-if="glFeatures.enforceRunnerTokenExpiresAt"
+ :empty-value="s__('Runners|Never expires')"
+ >
+ <template #label>
+ {{ s__('Runners|Token expiry') }}
+ <help-popover :options="tokenExpirationHelpPopoverOptions">
+ <p>
+ {{
+ s__(
+ 'Runners|Runner authentication tokens will expire based on a set interval. They will automatically rotate once expired.',
+ )
+ }}
+ </p>
+ <p class="gl-mb-0">
+ <gl-link
+ :href="tokenExpirationHelpUrl"
+ target="_blank"
+ class="gl-reset-font-size"
+ >{{ __('Learn more') }}</gl-link
+ >
+ </p>
+ </help-popover>
+ </template>
+ <template v-if="runner.tokenExpiresAt" #value>
+ <time-ago :time="runner.tokenExpiresAt" />
+ </template>
+ </runner-detail>
<runner-detail :label="s__('Runners|Tags')">
<template v-if="tagList.length" #value>
<runner-tags class="gl-vertical-align-middle" :tag-list="tagList" size="sm" />
diff --git a/app/assets/javascripts/runner/components/runner_header.vue b/app/assets/javascripts/runner/components/runner_header.vue
index abc07cec1ad..874c234ca4c 100644
--- a/app/assets/javascripts/runner/components/runner_header.vue
+++ b/app/assets/javascripts/runner/components/runner_header.vue
@@ -38,31 +38,33 @@ export default {
</script>
<template>
<div
- class="gl-display-flex gl-align-items-center gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-gap-3 gl-flex-wrap gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
- <div>
+ <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap">
<runner-status-badge :runner="runner" />
<runner-type-badge v-if="runner" :type="runner.runnerType" />
- <template v-if="runner.createdAt">
- <gl-sprintf :message="__('%{runner} created %{timeago}')">
- <template #runner>
- <strong>{{ heading }}</strong>
- <gl-icon
- v-if="runner.locked"
- v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
- name="lock"
- :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
- />
- </template>
- <template #timeago>
- <time-ago :time="runner.createdAt" />
- </template>
- </gl-sprintf>
- </template>
- <template v-else>
- <strong>{{ heading }}</strong>
- </template>
+ <span>
+ <template v-if="runner.createdAt">
+ <gl-sprintf :message="__('%{runner} created %{timeago}')">
+ <template #runner>
+ <strong>{{ heading }}</strong>
+ <gl-icon
+ v-if="runner.locked"
+ v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
+ name="lock"
+ :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
+ />
+ </template>
+ <template #timeago>
+ <time-ago :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </template>
+ <template v-else>
+ <strong>{{ heading }}</strong>
+ </template>
+ </span>
</div>
- <div class="gl-ml-auto gl-flex-shrink-0"><slot name="actions"></slot></div>
+ <div class="gl-display-flex gl-gap-3 gl-flex-wrap"><slot name="actions"></slot></div>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index 2e406f71792..26f1f3ce08c 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -1,24 +1,17 @@
<script>
import { GlFormCheckbox, 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 { s__ } from '~/locale';
import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
import { formatJobCount, tableField } from '../utils';
-import RunnerSummaryCell from './cells/runner_summary_cell.vue';
+import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue';
import RunnerStatusPopover from './runner_status_popover.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
-import RunnerTags from './runner_tags.vue';
const defaultFields = [
tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }),
- tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
- tableField({ key: 'version', label: __('Version') }),
- tableField({ key: 'jobCount', label: __('Jobs') }),
- tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }),
- tableField({ key: 'contactedAt', label: __('Last contact') }),
- tableField({ key: 'actions', label: '' }),
+ tableField({ key: 'summary', label: s__('Runners|Runner') }),
+ tableField({ key: 'actions', label: '', thClasses: ['gl-w-15p'] }),
];
export default {
@@ -26,11 +19,8 @@ export default {
GlFormCheckbox,
GlTableLite,
GlSkeletonLoader,
- TooltipOnTruncate,
- TimeAgo,
RunnerStatusPopover,
- RunnerSummaryCell,
- RunnerTags,
+ RunnerStackedSummaryCell,
RunnerStatusCell,
},
directives: {
@@ -74,6 +64,8 @@ export default {
};
},
fields() {
+ const fields = defaultFields;
+
if (this.checkable) {
const checkboxField = tableField({
key: 'checkbox',
@@ -81,9 +73,9 @@ export default {
thClasses: ['gl-w-9'],
tdClass: ['gl-text-center'],
});
- return [checkboxField, ...defaultFields];
+ return [checkboxField, ...fields];
}
- return defaultFields;
+ return fields;
},
},
methods: {
@@ -141,30 +133,11 @@ export default {
</template>
<template #cell(summary)="{ item, index }">
- <runner-summary-cell :runner="item">
+ <runner-stacked-summary-cell :runner="item">
<template #runner-name="{ runner }">
<slot name="runner-name" :runner="runner" :index="index"></slot>
</template>
- </runner-summary-cell>
- </template>
-
- <template #cell(version)="{ item: { version } }">
- <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="version">
- {{ version }}
- </tooltip-on-truncate>
- </template>
-
- <template #cell(jobCount)="{ item: { jobCount } }">
- {{ formatJobCount(jobCount) }}
- </template>
-
- <template #cell(tagList)="{ item: { tagList } }">
- <runner-tags :tag-list="tagList" size="sm" />
- </template>
-
- <template #cell(contactedAt)="{ item: { contactedAt } }">
- <time-ago v-if="contactedAt" :time="contactedAt" />
- <template v-else>{{ __('Never') }}</template>
+ </runner-stacked-summary-cell>
</template>
<template #cell(actions)="{ item }">
diff --git a/app/assets/javascripts/runner/components/runner_name.vue b/app/assets/javascripts/runner/components/runner_name.vue
index 8e495125e03..d4ecfd2d776 100644
--- a/app/assets/javascripts/runner/components/runner_name.vue
+++ b/app/assets/javascripts/runner/components/runner_name.vue
@@ -14,5 +14,7 @@ export default {
};
</script>
<template>
- <span>#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span>
+ <span class="gl-font-weight-bold gl-vertical-align-middle"
+ >#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span
+ >
</template>
diff --git a/app/assets/javascripts/runner/components/runner_paused_badge.vue b/app/assets/javascripts/runner/components/runner_paused_badge.vue
index 27618290ce6..00fd84a48d8 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_DESCRIPTION } from '../constants';
+import { I18N_PAUSED, I18N_PAUSED_DESCRIPTION } from '../constants';
export default {
components: {
@@ -9,11 +9,17 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ I18N_PAUSED,
I18N_PAUSED_DESCRIPTION,
};
</script>
<template>
- <gl-badge v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION" variant="danger" v-bind="$attrs">
- {{ s__('Runners|paused') }}
+ <gl-badge
+ v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION"
+ variant="warning"
+ icon="status-paused"
+ v-bind="$attrs"
+ >
+ {{ $options.I18N_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 2c1d2fc2b10..84008e8eee8 100644
--- a/app/assets/javascripts/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/runner/components/runner_projects.vue
@@ -1,11 +1,13 @@
<script>
-import { GlSkeletonLoader } from '@gitlab/ui';
+import { GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
import { sprintf, formatNumber } from '~/locale';
import { createAlert } from '~/flash';
import runnerProjectsQuery from '../graphql/show/runner_projects.query.graphql';
import {
I18N_ASSIGNED_PROJECTS,
- I18N_NONE,
+ I18N_CLEAR_FILTER_PROJECTS,
+ I18N_FILTER_PROJECTS,
+ I18N_NO_PROJECTS_FOUND,
I18N_FETCH_ERROR,
RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
} from '../constants';
@@ -14,9 +16,12 @@ import { captureException } from '../sentry_utils';
import RunnerAssignedItem from './runner_assigned_item.vue';
import RunnerPagination from './runner_pagination.vue';
+const SHORT_SEARCH_LENGTH = 3;
+
export default {
name: 'RunnerProjects',
components: {
+ GlSearchBoxByType,
GlSkeletonLoader,
RunnerAssignedItem,
RunnerPagination,
@@ -35,6 +40,7 @@ export default {
pageInfo: {},
count: 0,
},
+ search: '',
pagination: {},
};
},
@@ -61,9 +67,10 @@ export default {
},
computed: {
variables() {
- const { id } = this.runner;
+ const { search, runner } = this;
return {
- id,
+ id: runner.id,
+ search: search.length >= SHORT_SEARCH_LENGTH ? search : '',
...getPaginationVariables(this.pagination, RUNNER_DETAILS_PROJECTS_PAGE_SIZE),
};
},
@@ -80,22 +87,38 @@ export default {
isOwner(projectId) {
return projectId === this.projects.ownerProjectId;
},
+ onSearchInput(search) {
+ this.search = search;
+ this.pagination = {};
+ },
onPaginationInput(value) {
this.pagination = value;
},
},
- I18N_NONE,
+ RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+ I18N_CLEAR_FILTER_PROJECTS,
+ I18N_FILTER_PROJECTS,
+ I18N_NO_PROJECTS_FOUND,
};
</script>
<template>
<div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid">
- <h3 class="gl-font-lg gl-mt-5 gl-mb-0">
+ <h3 class="gl-font-lg gl-mt-5">
{{ heading }}
</h3>
+ <gl-search-box-by-type
+ :is-loading="loading"
+ :clear-button-title="$options.I18N_CLEAR_FILTER_PROJECTS"
+ :placeholder="$options.I18N_FILTER_PROJECTS"
+ debounce="500"
+ class="gl-w-28"
+ :value="search"
+ @input="onSearchInput"
+ />
- <div v-if="loading" class="gl-py-5">
- <gl-skeleton-loader />
+ <div v-if="!projects.items.length && loading" class="gl-py-5">
+ <gl-skeleton-loader v-for="i in $options.RUNNER_DETAILS_PROJECTS_PAGE_SIZE" :key="i" />
</div>
<template v-else-if="projects.items.length">
<runner-assigned-item
@@ -110,7 +133,7 @@ export default {
:is-owner="isOwner(project.id)"
/>
</template>
- <span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span>
+ <div v-else class="gl-py-5 gl-text-gray-500">{{ $options.I18N_NO_PROJECTS_FOUND }}</div>
<runner-pagination
:disabled="loading"
diff --git a/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue b/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue
new file mode 100644
index 00000000000..e3a9a9fd8a4
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue
@@ -0,0 +1,58 @@
+<script>
+import allChangesCommittedSvg from '@gitlab/svgs/dist/illustrations/multi-editor_all_changes_committed_empty.svg';
+import { GlBanner } from '@gitlab/ui';
+
+import { s__ } from '~/locale';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+
+const I18N_TITLE = s__("Runners|We've made some changes and want your feedback");
+const I18N_DESCRIPTION = s__(
+ "Runners|We want you to be able to manage your runners easily and efficiently from this page, and we are making changes to get there. Give us feedback on how we're doing!",
+);
+const I18N_LINK = s__('Runners|Add your feedback in the issue');
+
+// use a data url instead getting it from via HTML data-* attributes to simplify removal of this feature flag
+const ILLUSTRATION_URL = `data:image/svg+xml;utf8,${encodeURIComponent(allChangesCommittedSvg)}`;
+const ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/371621';
+const STORAGE_KEY = 'runner_list_stacked_layout_feedback_dismissed';
+
+export default {
+ components: {
+ GlBanner,
+ LocalStorageSync,
+ },
+ data() {
+ return {
+ isDismissed: false,
+ };
+ },
+ methods: {
+ onClose() {
+ this.isDismissed = true;
+ },
+ },
+ I18N_TITLE,
+ I18N_DESCRIPTION,
+ I18N_LINK,
+ ILLUSTRATION_URL,
+ ISSUE_URL,
+ STORAGE_KEY,
+};
+</script>
+
+<template>
+ <div>
+ <local-storage-sync v-model="isDismissed" :storage-key="$options.STORAGE_KEY" />
+ <gl-banner
+ v-if="!isDismissed"
+ :svg-path="$options.ILLUSTRATION_URL"
+ :title="$options.I18N_TITLE"
+ :button-text="$options.I18N_LINK"
+ :button-link="$options.ISSUE_URL"
+ class="gl-my-5"
+ @close="onClose"
+ >
+ <p>{{ $options.I18N_DESCRIPTION }}</p>
+ </gl-banner>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_status_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue
index 073d0a49f59..d084408781e 100644
--- a/app/assets/javascripts/runner/components/runner_status_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_status_badge.vue
@@ -1,8 +1,12 @@
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { __, s__, sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import {
+ I18N_STATUS_ONLINE,
+ I18N_STATUS_NEVER_CONTACTED,
+ I18N_STATUS_OFFLINE,
+ I18N_STATUS_STALE,
I18N_ONLINE_TIMEAGO_TOOLTIP,
I18N_NEVER_CONTACTED_TOOLTIP,
I18N_OFFLINE_TIMEAGO_TOOLTIP,
@@ -39,26 +43,30 @@ export default {
switch (this.runner?.status) {
case STATUS_ONLINE:
return {
+ icon: 'status-active',
variant: 'success',
- label: s__('Runners|online'),
+ label: I18N_STATUS_ONLINE,
tooltip: this.timeAgoTooltip(I18N_ONLINE_TIMEAGO_TOOLTIP),
};
case STATUS_NEVER_CONTACTED:
return {
+ icon: 'time-out',
variant: 'muted',
- label: s__('Runners|never contacted'),
+ label: I18N_STATUS_NEVER_CONTACTED,
tooltip: I18N_NEVER_CONTACTED_TOOLTIP,
};
case STATUS_OFFLINE:
return {
+ icon: 'time-out',
variant: 'muted',
- label: s__('Runners|offline'),
+ label: I18N_STATUS_OFFLINE,
tooltip: this.timeAgoTooltip(I18N_OFFLINE_TIMEAGO_TOOLTIP),
};
case STATUS_STALE:
return {
+ icon: 'time-out',
variant: 'warning',
- label: s__('Runners|stale'),
+ label: I18N_STATUS_STALE,
// runner may have contacted (or not) and be stale: consider both cases.
tooltip: this.runner.contactedAt
? this.timeAgoTooltip(I18N_STALE_TIMEAGO_TOOLTIP)
@@ -77,7 +85,13 @@ export default {
};
</script>
<template>
- <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" :variant="badge.variant" v-bind="$attrs">
+ <gl-badge
+ v-if="badge"
+ v-gl-tooltip="badge.tooltip"
+ :variant="badge.variant"
+ :icon="badge.icon"
+ v-bind="$attrs"
+ >
{{ badge.label }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue
index 797d2a35b2c..38e566f9f53 100644
--- a/app/assets/javascripts/runner/components/runner_tags.vue
+++ b/app/assets/javascripts/runner/components/runner_tags.vue
@@ -20,7 +20,7 @@ export default {
};
</script>
<template>
- <span>
+ <span v-if="tagList && tagList.length">
<runner-tag
v-for="tag in tagList"
:key="tag"
diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue
index b885dcefdcb..f568f914004 100644
--- a/app/assets/javascripts/runner/components/runner_type_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_type_badge.vue
@@ -1,26 +1,31 @@
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { s__ } from '~/locale';
import {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
+ I18N_INSTANCE_TYPE,
I18N_INSTANCE_RUNNER_DESCRIPTION,
+ I18N_GROUP_TYPE,
I18N_GROUP_RUNNER_DESCRIPTION,
+ I18N_PROJECT_TYPE,
I18N_PROJECT_RUNNER_DESCRIPTION,
} from '../constants';
const BADGE_DATA = {
[INSTANCE_TYPE]: {
- text: s__('Runners|shared'),
+ icon: 'users',
+ text: I18N_INSTANCE_TYPE,
tooltip: I18N_INSTANCE_RUNNER_DESCRIPTION,
},
[GROUP_TYPE]: {
- text: s__('Runners|group'),
+ icon: 'group',
+ text: I18N_GROUP_TYPE,
tooltip: I18N_GROUP_RUNNER_DESCRIPTION,
},
[PROJECT_TYPE]: {
- text: s__('Runners|specific'),
+ icon: 'project',
+ text: I18N_PROJECT_TYPE,
tooltip: I18N_PROJECT_RUNNER_DESCRIPTION,
},
};
@@ -50,7 +55,13 @@ export default {
};
</script>
<template>
- <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" variant="info" v-bind="$attrs">
+ <gl-badge
+ v-if="badge"
+ v-gl-tooltip="badge.tooltip"
+ variant="muted"
+ :icon="badge.icon"
+ v-bind="$attrs"
+ >
{{ badge.text }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
index c1ad5da3ab9..97ee8ec3eef 100644
--- a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
+++ b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
@@ -1,7 +1,7 @@
-import { __, s__ } from '~/locale';
+import { __ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
-import { PARAM_KEY_PAUSED } from '../../constants';
+import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants';
const options = [
{ value: 'true', title: __('Yes') },
@@ -10,7 +10,7 @@ const options = [
export const pausedTokenConfig = {
icon: 'pause',
- title: s__('Runners|Paused'),
+ title: I18N_PAUSED,
type: PARAM_KEY_PAUSED,
token: BaseToken,
unique: true,
diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
index 9e6f63d3f7c..f5c42d120fb 100644
--- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
+++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
@@ -1,7 +1,11 @@
-import { __, s__ } from '~/locale';
+import { __ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import {
+ I18N_STATUS_ONLINE,
+ I18N_STATUS_NEVER_CONTACTED,
+ I18N_STATUS_OFFLINE,
+ I18N_STATUS_STALE,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NEVER_CONTACTED,
@@ -10,10 +14,10 @@ import {
} from '../../constants';
const options = [
- { value: STATUS_ONLINE, title: s__('Runners|Online') },
- { value: STATUS_OFFLINE, title: s__('Runners|Offline') },
- { value: STATUS_NEVER_CONTACTED, title: s__('Runners|Never contacted') },
- { value: STATUS_STALE, title: s__('Runners|Stale') },
+ { value: STATUS_ONLINE, title: I18N_STATUS_ONLINE },
+ { value: STATUS_OFFLINE, title: I18N_STATUS_OFFLINE },
+ { value: STATUS_NEVER_CONTACTED, title: I18N_STATUS_NEVER_CONTACTED },
+ { value: STATUS_STALE, title: I18N_STATUS_STALE },
];
export const statusTokenConfig = {
diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue
index 93e54ebe7f4..4df59f5a0c9 100644
--- a/app/assets/javascripts/runner/components/stat/runner_stats.vue
+++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue
@@ -1,7 +1,13 @@
<script>
-import { s__ } from '~/locale';
import RunnerSingleStat from '~/runner/components/stat/runner_single_stat.vue';
-import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants';
+import {
+ I18N_STATUS_ONLINE,
+ I18N_STATUS_OFFLINE,
+ I18N_STATUS_STALE,
+ STATUS_ONLINE,
+ STATUS_OFFLINE,
+ STATUS_STALE,
+} from '../../constants';
export default {
components: {
@@ -29,8 +35,8 @@ export default {
skip: this.statusCountSkip(STATUS_ONLINE),
variables: { ...this.variables, status: STATUS_ONLINE },
variant: 'success',
- title: s__('Runners|Online runners'),
- metaText: s__('Runners|online'),
+ title: I18N_STATUS_ONLINE,
+ metaIcon: 'status-active',
},
},
{
@@ -39,8 +45,8 @@ export default {
skip: this.statusCountSkip(STATUS_OFFLINE),
variables: { ...this.variables, status: STATUS_OFFLINE },
variant: 'muted',
- title: s__('Runners|Offline runners'),
- metaText: s__('Runners|offline'),
+ title: I18N_STATUS_OFFLINE,
+ metaIcon: 'status-waiting',
},
},
{
@@ -49,8 +55,8 @@ export default {
skip: this.statusCountSkip(STATUS_STALE),
variables: { ...this.variables, status: STATUS_STALE },
variant: 'warning',
- title: s__('Runners|Stale runners'),
- metaText: s__('Runners|stale'),
+ title: I18N_STATUS_STALE,
+ metaIcon: 'time-out',
},
},
];
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index ed1afcbf691..3009577599f 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -23,6 +23,12 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
);
export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects');
+// Status
+export const I18N_STATUS_ONLINE = s__('Runners|Online');
+export const I18N_STATUS_NEVER_CONTACTED = s__('Runners|Never contacted');
+export const I18N_STATUS_OFFLINE = s__('Runners|Offline');
+export const I18N_STATUS_STALE = s__('Runners|Stale');
+
// Status help popover
export const I18N_STATUS_POPOVER_TITLE = s__('Runners|Runner statuses');
@@ -62,6 +68,7 @@ export const I18N_STALE_NEVER_CONTACTED_TOOLTIP = s__(
export const I18N_EDIT = __('Edit');
export const I18N_PAUSE = __('Pause');
+export const I18N_PAUSED = s__('Runners|Paused');
export const I18N_PAUSE_TOOLTIP = s__('Runners|Pause from accepting jobs');
export const I18N_PAUSED_DESCRIPTION = s__('Runners|Not accepting jobs');
@@ -77,20 +84,27 @@ export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__(
);
export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
+// List
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.',
);
+export const I18N_VERSION_LABEL = s__('Runners|Version %{version}');
+export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}');
+export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}');
// Runner details
export const I18N_DETAILS = s__('Runners|Details');
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
+export const I18N_FILTER_PROJECTS = s__('Runners|Filter projects');
+export const I18N_CLEAR_FILTER_PROJECTS = __('Clear');
export const I18N_NONE = __('None');
export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.');
+export const I18N_NO_PROJECTS_FOUND = __('No projects found');
// Styles
-export const RUNNER_TAG_BADGE_VARIANT = 'neutral';
+export const RUNNER_TAG_BADGE_VARIANT = 'info';
export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
// Filtered search parameter names
diff --git a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
index ce23bddb898..a12ba7a751a 100644
--- a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
@@ -9,6 +9,7 @@ fragment ListItemShared on CiRunner {
locked
jobCount
tagList
+ createdAt
contactedAt
status(legacyMode: null)
userPermissions {
diff --git a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
index 499c0156770..b5689ff7687 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
@@ -17,6 +17,7 @@ fragment RunnerDetailsShared on CiRunner {
createdAt
status(legacyMode: null)
contactedAt
+ tokenExpiresAt
version
editAdminUrl
userPermissions {
diff --git a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
index acc4a641565..e42648b3079 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
+++ b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
@@ -2,6 +2,7 @@
query getRunnerProjects(
$id: CiRunnerID!
+ $search: String
$first: Int
$last: Int
$before: String
@@ -13,7 +14,7 @@ query getRunnerProjects(
id
}
projectCount
- projects(first: $first, last: $last, before: $before, after: $after) {
+ projects(search: $search, first: $first, last: $last, before: $before, after: $after) {
nodes {
id
avatarUrl
diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/runner/group_runner_show/index.js
index 62a0dab9211..e75f337b38e 100644
--- a/app/assets/javascripts/runner/group_runner_show/index.js
+++ b/app/assets/javascripts/runner/group_runner_show/index.js
@@ -1,11 +1,14 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
import GroupRunnerShowApp from './group_runner_show_app.vue';
Vue.use(VueApollo);
export const initGroupRunnerShow = (selector = '#js-group-runner-show') => {
+ showAlertFromLocalStorage();
+
const el = document.querySelector(selector);
if (!el) {
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 a82411a2120..70826a6bfa1 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -13,6 +13,7 @@ import {
import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
+import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
@@ -37,6 +38,7 @@ export default {
components: {
GlLink,
RegistrationDropdown,
+ RunnerStackedLayoutBanner,
RunnerFilteredSearchBar,
RunnerList,
RunnerListEmptyState,
@@ -50,7 +52,8 @@ export default {
props: {
registrationToken: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
groupFullPath: {
type: String,
@@ -178,6 +181,8 @@ export default {
<template>
<div>
+ <runner-stacked-layout-banner />
+
<div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
ref="runner-type-tabs"
@@ -191,6 +196,7 @@ export default {
/>
<registration-dropdown
+ v-if="registrationToken"
class="gl-ml-auto"
:registration-token="registrationToken"
:type="$options.GROUP_TYPE"
diff --git a/app/assets/javascripts/runner/admin_runner_edit/index.js b/app/assets/javascripts/runner/runner_edit/index.js
index a2ac5731a62..5b2ddb8f68e 100644
--- a/app/assets/javascripts/runner/admin_runner_edit/index.js
+++ b/app/assets/javascripts/runner/runner_edit/index.js
@@ -1,11 +1,11 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import AdminRunnerEditApp from './admin_runner_edit_app.vue';
+import RunnerEditApp from './runner_edit_app.vue';
Vue.use(VueApollo);
-export const initAdminRunnerEdit = (selector = '#js-admin-runner-edit') => {
+export const initRunnerEdit = (selector) => {
const el = document.querySelector(selector);
if (!el) {
@@ -22,7 +22,7 @@ export const initAdminRunnerEdit = (selector = '#js-admin-runner-edit') => {
el,
apolloProvider,
render(h) {
- return h(AdminRunnerEditApp, {
+ return h(RunnerEditApp, {
props: {
runnerId,
runnerPath,
diff --git a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue b/app/assets/javascripts/runner/runner_edit/runner_edit_app.vue
index 40787cf72da..879162916a9 100644
--- a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue
+++ b/app/assets/javascripts/runner/runner_edit/runner_edit_app.vue
@@ -9,7 +9,7 @@ import runnerFormQuery from '../graphql/edit/runner_form.query.graphql';
import { captureException } from '../sentry_utils';
export default {
- name: 'AdminRunnerEditApp',
+ name: 'RunnerEditApp',
components: {
RunnerHeader,
RunnerUpdateForm,
diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/runner/utils.js
index cb2917a92fd..1ca0a9e86b5 100644
--- a/app/assets/javascripts/runner/utils.js
+++ b/app/assets/javascripts/runner/utils.js
@@ -70,3 +70,14 @@ export const getPaginationVariables = (pagination, pageSize = 10) => {
// Get the first N items
return { first: pageSize };
};
+
+/**
+ * Turns a server-provided interval integer represented as a string into an
+ * integer that the frontend can use.
+ *
+ * @param {String} interval - String to convert
+ * @returns Parsed integer
+ */
+export const parseInterval = (interval) => {
+ return typeof interval === 'string' ? parseInt(interval, 10) : null;
+};
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index d9d4056466a..446ab7f433c 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -1,11 +1,11 @@
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
import { queryToObject } from '~/lib/utils/url_utility';
-import Project from '~/pages/projects/project';
import refreshCounts from '~/pages/search/show/refresh_counts';
import { initSidebar } from './sidebar';
import { initSearchSort } from './sort';
import createStore from './store';
import { initTopbar } from './topbar';
+import { initBlobRefSwitcher } from './under_topbar';
export const initSearchApp = () => {
const query = queryToObject(window.location.search);
@@ -18,5 +18,5 @@ export const initSearchApp = () => {
setHighlightClass(query.search); // Code Highlighting
refreshCounts(); // Other Scope Tab Counts
- Project.initRefSwitcher(); // Code Search Branch Picker
+ initBlobRefSwitcher(); // Code Search Branch Picker
};
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
index 5653cddda60..ff639d538b3 100644
--- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
@@ -144,9 +144,9 @@ export default {
/>
<gl-dropdown-item
class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2"
- :is-check-item="true"
+ is-check-item
:is-checked="isSelected($options.ANY_OPTION)"
- :is-check-centered="true"
+ is-check-centered
@click="updateDropdown($options.ANY_OPTION)"
>
<span data-testid="item-title">{{ $options.ANY_OPTION.name }}</span>
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
index a4254a355a2..70156142365 100644
--- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
@@ -53,9 +53,9 @@ export default {
<template>
<gl-dropdown-item
- :is-check-item="true"
+ is-check-item
:is-checked="isSelected"
- :is-check-centered="true"
+ is-check-centered
@click="$emit('change', item)"
>
<div class="gl-display-flex gl-align-items-center">
diff --git a/app/assets/javascripts/search/under_topbar/index.js b/app/assets/javascripts/search/under_topbar/index.js
new file mode 100644
index 00000000000..8e50c6655dd
--- /dev/null
+++ b/app/assets/javascripts/search/under_topbar/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
+
+Vue.use(Translate);
+
+export const initBlobRefSwitcher = () => {
+ const el = document.getElementById('js-blob-ref-switcher');
+
+ if (!el) return false;
+
+ const { projectId, ref, fieldName } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(RefSelector, {
+ props: {
+ projectId,
+ value: ref,
+ },
+ on: {
+ input(selected) {
+ visitUrl(setUrlParams({ [fieldName]: selected }));
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 5a9ef832e05..77216408c39 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -157,7 +157,7 @@ export const SCANNER_NAMES_MAP = {
COVERAGE_FUZZING: COVERAGE_FUZZING_NAME,
SECRET_DETECTION: SECRET_DETECTION_NAME,
DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME,
- GENERIC: s__('ciReport|Manually Added'),
+ GENERIC: s__('ciReport|Manually added'),
};
export const securityFeatures = [
diff --git a/app/assets/javascripts/set_status_modal/constants.js b/app/assets/javascripts/set_status_modal/constants.js
new file mode 100644
index 00000000000..53e64db1497
--- /dev/null
+++ b/app/assets/javascripts/set_status_modal/constants.js
@@ -0,0 +1,14 @@
+import { timeRanges } from '~/vue_shared/constants';
+import { __ } from '~/locale';
+
+export const NEVER_TIME_RANGE = {
+ label: __('Never'),
+ name: 'never',
+};
+
+export const TIME_RANGES_WITH_NEVER = [NEVER_TIME_RANGE, ...timeRanges];
+
+export const AVAILABILITY_STATUS = {
+ BUSY: 'busy',
+ NOT_SET: 'not_set',
+};
diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue
new file mode 100644
index 00000000000..7f9a30b7ff1
--- /dev/null
+++ b/app/assets/javascripts/set_status_modal/set_status_form.vue
@@ -0,0 +1,231 @@
+<script>
+import {
+ GlButton,
+ GlTooltipDirective,
+ GlIcon,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormInputGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlFormGroup,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
+import $ from 'jquery';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
+import * as Emoji from '~/emoji';
+import { s__ } from '~/locale';
+import { TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS } from './constants';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormInputGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlFormGroup,
+ EmojiPicker: () => import('~/emoji/components/picker.vue'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ defaultEmoji: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ emoji: {
+ type: String,
+ required: true,
+ },
+ message: {
+ type: String,
+ required: true,
+ },
+ availability: {
+ type: Boolean,
+ required: true,
+ },
+ clearStatusAfter: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ currentClearStatusAfter: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ defaultEmojiTag: '',
+ emojiTag: '',
+ };
+ },
+ computed: {
+ isCustomEmoji() {
+ return this.emoji !== this.defaultEmoji;
+ },
+ isDirty() {
+ return Boolean(this.message.length || this.isCustomEmoji);
+ },
+ noEmoji() {
+ return this.emojiTag === '';
+ },
+ },
+ mounted() {
+ this.setupEmojiListAndAutocomplete();
+ },
+ methods: {
+ async setupEmojiListAndAutocomplete() {
+ const emojiAutocomplete = new GfmAutoComplete();
+ emojiAutocomplete.setup($(this.$refs.statusMessageField.$el), { emojis: true });
+
+ if (this.emoji) {
+ this.emojiTag = Emoji.glEmojiTag(this.emoji);
+ }
+ this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
+
+ this.setDefaultEmoji();
+ },
+ setDefaultEmoji() {
+ const { emojiTag } = this;
+ const hasStatusMessage = Boolean(this.message.length);
+ if (hasStatusMessage && emojiTag) {
+ return;
+ }
+
+ if (hasStatusMessage) {
+ this.emojiTag = this.defaultEmojiTag;
+ } else if (emojiTag === this.defaultEmojiTag) {
+ this.clearEmoji();
+ }
+ },
+ handleEmojiClick(emoji) {
+ this.$emit('emoji-click', emoji);
+
+ this.emojiTag = Emoji.glEmojiTag(emoji);
+ },
+ clearEmoji() {
+ if (this.emojiTag) {
+ this.emojiTag = '';
+ }
+ },
+ clearStatusInputs() {
+ this.$emit('emoji-click', '');
+ this.$emit('message-input', '');
+ this.clearEmoji();
+ },
+ },
+ TIME_RANGES_WITH_NEVER,
+ AVAILABILITY_STATUS,
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
+ i18n: {
+ statusMessagePlaceholder: s__(`SetStatusModal|What's your status?`),
+ clearStatusButtonLabel: s__('SetStatusModal|Clear status'),
+ availabilityCheckboxLabel: s__('SetStatusModal|Busy'),
+ availabilityCheckboxHelpText: s__(
+ 'SetStatusModal|An indicator appears next to your name and avatar',
+ ),
+ clearStatusAfterDropdownLabel: s__('SetStatusModal|Clear status after'),
+ clearStatusAfterMessage: s__('SetStatusModal|Your status resets on %{date}.'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-form-input-group class="gl-mb-5">
+ <gl-form-input
+ ref="statusMessageField"
+ :value="message"
+ :placeholder="$options.i18n.statusMessagePlaceholder"
+ @keyup="setDefaultEmoji"
+ @input="$emit('message-input', $event)"
+ @keyup.enter.prevent
+ />
+ <template #prepend>
+ <emoji-picker
+ dropdown-class="gl-h-full"
+ toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ boundary="viewport"
+ :right="false"
+ @click="handleEmojiClick"
+ >
+ <template #button-content>
+ <span
+ v-if="noEmoji"
+ class="no-emoji-placeholder position-relative"
+ data-testid="no-emoji-placeholder"
+ >
+ <gl-icon name="slight-smile" class="award-control-icon-neutral" />
+ <gl-icon name="smiley" class="award-control-icon-positive" />
+ <gl-icon name="smile" class="award-control-icon-super-positive" />
+ </span>
+ <span v-else>
+ <span
+ v-safe-html:[$options.safeHtmlConfig]="emojiTag"
+ data-testid="selected-emoji"
+ ></span>
+ </span>
+ </template>
+ </emoji-picker>
+ </template>
+ <template v-if="isDirty" #append>
+ <gl-button
+ v-gl-tooltip.bottom
+ :title="$options.i18n.clearStatusButtonLabel"
+ :aria-label="$options.i18n.clearStatusButtonLabel"
+ icon="close"
+ class="js-clear-user-status-button"
+ @click="clearStatusInputs"
+ />
+ </template>
+ </gl-form-input-group>
+
+ <gl-form-checkbox
+ :checked="availability"
+ class="gl-mb-5"
+ data-testid="user-availability-checkbox"
+ @input="$emit('availability-input', $event)"
+ >
+ {{ $options.i18n.availabilityCheckboxLabel }}
+ <template #help>
+ {{ $options.i18n.availabilityCheckboxHelpText }}
+ </template>
+ </gl-form-checkbox>
+
+ <gl-form-group :label="$options.i18n.clearStatusAfterDropdownLabel" class="gl-mb-0">
+ <gl-dropdown
+ block
+ :text="clearStatusAfter.label"
+ data-testid="clear-status-at-dropdown"
+ toggle-class="gl-mb-0 gl-form-input-md"
+ >
+ <gl-dropdown-item
+ v-for="after in $options.TIME_RANGES_WITH_NEVER"
+ :key="after.name"
+ :data-testid="after.name"
+ @click="$emit('clear-status-after-click', after)"
+ >{{ after.label }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+
+ <template v-if="currentClearStatusAfter.length" #description>
+ <span data-testid="clear-status-at-message">
+ <gl-sprintf :message="$options.i18n.clearStatusAfterMessage">
+ <template #date>{{ currentClearStatusAfter }}</template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index 2cdec8fc481..80b1cb8c4d5 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -1,55 +1,21 @@
<script>
-import {
- GlButton,
- GlToast,
- GlModal,
- GlTooltipDirective,
- GlIcon,
- GlFormCheckbox,
- GlFormInput,
- GlFormInputGroup,
- GlDropdown,
- GlDropdownItem,
- GlSafeHtmlDirective,
-} from '@gitlab/ui';
-import $ from 'jquery';
+import { GlToast, GlTooltipDirective, GlSafeHtmlDirective, GlModal } from '@gitlab/ui';
import Vue from 'vue';
-import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
-import * as Emoji from '~/emoji';
import createFlash from '~/flash';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { __, s__, sprintf } from '~/locale';
+import { s__ } from '~/locale';
import { updateUserStatus } from '~/rest_api';
-import { timeRanges } from '~/vue_shared/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isUserBusy } from './utils';
-
-export const AVAILABILITY_STATUS = {
- BUSY: 'busy',
- NOT_SET: 'not_set',
-};
+import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants';
+import SetStatusForm from './set_status_form.vue';
Vue.use(GlToast);
-const statusTimeRanges = [
- {
- label: __('Never'),
- name: 'never',
- },
- ...timeRanges,
-];
-
export default {
components: {
- GlButton,
- GlIcon,
GlModal,
- GlFormCheckbox,
- GlFormInput,
- GlFormInputGroup,
- GlDropdown,
- GlDropdownItem,
- EmojiPicker: () => import('~/emoji/components/picker.vue'),
+ SetStatusForm,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -85,26 +51,12 @@ export default {
return {
defaultEmojiTag: '',
emoji: this.currentEmoji,
- emojiMenu: null,
- emojiTag: '',
message: this.currentMessage,
modalId: 'set-user-status-modal',
- noEmoji: true,
availability: isUserBusy(this.currentAvailability),
- clearStatusAfter: statusTimeRanges[0],
- clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), {
- date: this.currentClearStatusAfter,
- }),
+ clearStatusAfter: NEVER_TIME_RANGE,
};
},
- computed: {
- isCustomEmoji() {
- return this.emoji !== this.defaultEmoji;
- },
- isDirty() {
- return Boolean(this.message.length || this.isCustomEmoji);
- },
- },
mounted() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@@ -112,62 +64,10 @@ export default {
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
- setupEmojiListAndAutocomplete() {
- const emojiAutocomplete = new GfmAutoComplete();
- emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true });
-
- Emoji.initEmojiMap()
- .then(() => {
- if (this.emoji) {
- this.emojiTag = Emoji.glEmojiTag(this.emoji);
- }
- this.noEmoji = this.emoji === '';
- this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
-
- this.setDefaultEmoji();
- })
- .catch(() =>
- createFlash({
- message: __('Failed to load emoji list.'),
- }),
- );
- },
- setDefaultEmoji() {
- const { emojiTag } = this;
- const hasStatusMessage = Boolean(this.message.length);
- if (hasStatusMessage && emojiTag) {
- return;
- }
-
- if (hasStatusMessage) {
- this.noEmoji = false;
- this.emojiTag = this.defaultEmojiTag;
- } else if (emojiTag === this.defaultEmojiTag) {
- this.noEmoji = true;
- this.clearEmoji();
- }
- },
- setEmoji(emoji) {
- this.emoji = emoji;
- this.noEmoji = false;
- this.clearEmoji();
-
- this.emojiTag = Emoji.glEmojiTag(this.emoji);
- },
- clearEmoji() {
- if (this.emojiTag) {
- this.emojiTag = '';
- }
- },
- clearStatusInputs() {
- this.emoji = '';
- this.message = '';
- this.noEmoji = true;
- this.clearEmoji();
- },
removeStatus() {
this.availability = false;
- this.clearStatusInputs();
+ this.emoji = '';
+ this.message = '';
this.setStatus();
},
setStatus() {
@@ -178,7 +78,7 @@ export default {
message,
availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
clearStatusAfter:
- clearStatusAfter.label === statusTimeRanges[0].label ? null : clearStatusAfter.shortcut,
+ clearStatusAfter.label === NEVER_TIME_RANGE.label ? null : clearStatusAfter.shortcut,
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);
@@ -197,11 +97,19 @@ export default {
this.closeModal();
},
- setClearStatusAfter(after) {
+ handleMessageInput(value) {
+ this.message = value;
+ },
+ handleEmojiClick(emoji) {
+ this.emoji = emoji;
+ },
+ handleClearStatusAfterClick(after) {
this.clearStatusAfter = after;
},
+ handleAvailabilityInput(value) {
+ this.availability = value;
+ },
},
- statusTimeRanges,
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
actionPrimary: { text: s__('SetStatusModal|Set status') },
actionSecondary: { text: s__('SetStatusModal|Remove status') },
@@ -215,85 +123,20 @@ export default {
:action-primary="$options.actionPrimary"
:action-secondary="$options.actionSecondary"
modal-class="set-user-status-modal"
- @shown="setupEmojiListAndAutocomplete"
@primary="setStatus"
@secondary="removeStatus"
>
- <input v-model="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" />
- <gl-form-input-group class="gl-mb-5">
- <gl-form-input
- ref="statusMessageField"
- v-model="message"
- :placeholder="s__(`SetStatusModal|What's your status?`)"
- class="js-status-message-field"
- name="user[status][message]"
- @keyup="setDefaultEmoji"
- @keyup.enter.prevent
- />
- <template #prepend>
- <emoji-picker
- dropdown-class="gl-h-full"
- toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
- boundary="viewport"
- :right="false"
- @click="setEmoji"
- >
- <template #button-content>
- <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
- <span
- v-show="noEmoji"
- class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
- >
- <gl-icon name="slight-smile" class="award-control-icon-neutral" />
- <gl-icon name="smiley" class="award-control-icon-positive" />
- <gl-icon name="smile" class="award-control-icon-super-positive" />
- </span>
- </template>
- </emoji-picker>
- </template>
- <template v-if="isDirty" #append>
- <gl-button
- v-gl-tooltip.bottom
- :title="s__('SetStatusModal|Clear status')"
- :aria-label="s__('SetStatusModal|Clear status')"
- icon="close"
- class="js-clear-user-status-button"
- @click="clearStatusInputs"
- />
- </template>
- </gl-form-input-group>
-
- <gl-form-checkbox
- v-model="availability"
- class="gl-mb-5"
- data-testid="user-availability-checkbox"
- >
- {{ s__('SetStatusModal|Busy') }}
- <template #help>
- {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
- </template>
- </gl-form-checkbox>
-
- <div class="form-group">
- <div class="gl-display-flex gl-align-items-baseline">
- <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
- <gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown">
- <gl-dropdown-item
- v-for="after in $options.statusTimeRanges"
- :key="after.name"
- :data-testid="after.name"
- @click="setClearStatusAfter(after)"
- >{{ after.label }}</gl-dropdown-item
- >
- </gl-dropdown>
- </div>
- <div
- v-if="currentClearStatusAfter.length"
- class="gl-mt-3 gl-text-gray-400 gl-font-sm"
- data-testid="clear-status-at-message"
- >
- {{ clearStatusAfterMessage }}
- </div>
- </div>
+ <set-status-form
+ :default-emoji="defaultEmoji"
+ :emoji="emoji"
+ :message="message"
+ :availability="availability"
+ :clear-status-after="clearStatusAfter"
+ :current-clear-status-after="currentClearStatusAfter"
+ @message-input="handleMessageInput"
+ @emoji-click="handleEmojiClick"
+ @clear-status-after-click="handleClearStatusAfterClick"
+ @availability-input="handleAvailabilityInput"
+ />
</gl-modal>
</template>
diff --git a/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue b/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue
new file mode 100644
index 00000000000..c709611e13d
--- /dev/null
+++ b/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue
@@ -0,0 +1,100 @@
+<script>
+import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
+import dateFormat from '~/lib/dateformat';
+import SetStatusForm from './set_status_form.vue';
+import { isUserBusy } from './utils';
+import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants';
+
+export default {
+ components: { SetStatusForm },
+ inject: ['fields'],
+ data() {
+ return {
+ emoji: this.fields.emoji.value,
+ message: this.fields.message.value,
+ availability: isUserBusy(this.fields.availability.value),
+ clearStatusAfter: NEVER_TIME_RANGE,
+ currentClearStatusAfter: this.fields.clearStatusAfter.value,
+ };
+ },
+ computed: {
+ clearStatusAfterInputValue() {
+ return this.clearStatusAfter.label === NEVER_TIME_RANGE.label
+ ? null
+ : this.clearStatusAfter.shortcut;
+ },
+ availabilityInputValue() {
+ return this.availability
+ ? this.$options.AVAILABILITY_STATUS.BUSY
+ : this.$options.AVAILABILITY_STATUS.NOT_SET;
+ },
+ },
+ mounted() {
+ this.$options.formEl = document.querySelector('form.js-edit-user');
+
+ if (!this.$options.formEl) return;
+
+ this.$options.formEl.addEventListener('ajax:success', this.handleFormSuccess);
+ },
+ beforeDestroy() {
+ if (!this.$options.formEl) return;
+
+ this.$options.formEl.removeEventListener('ajax:success', this.handleFormSuccess);
+ },
+ methods: {
+ handleMessageInput(value) {
+ this.message = value;
+ },
+ handleEmojiClick(emoji) {
+ this.emoji = emoji;
+ },
+ handleClearStatusAfterClick(after) {
+ this.clearStatusAfter = after;
+ },
+ handleAvailabilityInput(value) {
+ this.availability = value;
+ },
+ handleFormSuccess() {
+ if (!this.clearStatusAfter?.duration?.seconds) {
+ this.currentClearStatusAfter = '';
+
+ return;
+ }
+
+ const now = new Date();
+ const currentClearStatusAfterDate = new Date(
+ now.getTime() + secondsToMilliseconds(this.clearStatusAfter.duration.seconds),
+ );
+
+ this.currentClearStatusAfter = dateFormat(
+ currentClearStatusAfterDate,
+ "UTC:yyyy-mm-dd HH:MM:ss 'UTC'",
+ );
+ this.clearStatusAfter = NEVER_TIME_RANGE;
+ },
+ },
+ AVAILABILITY_STATUS,
+ formEl: null,
+};
+</script>
+
+<template>
+ <div>
+ <input :value="emoji" type="hidden" :name="fields.emoji.name" />
+ <input :value="message" type="hidden" :name="fields.message.name" />
+ <input :value="availabilityInputValue" type="hidden" :name="fields.availability.name" />
+ <input :value="clearStatusAfterInputValue" type="hidden" :name="fields.clearStatusAfter.name" />
+ <set-status-form
+ default-emoji="speech_balloon"
+ :emoji="emoji"
+ :message="message"
+ :availability="availability"
+ :clear-status-after="clearStatusAfter"
+ :current-clear-status-after="currentClearStatusAfter"
+ @message-input="handleMessageInput"
+ @emoji-click="handleEmojiClick"
+ @clear-status-after-click="handleClearStatusAfterClick"
+ @availability-input="handleAvailabilityInput"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/set_status_modal/utils.js b/app/assets/javascripts/set_status_modal/utils.js
index e17d95adb25..950091195d2 100644
--- a/app/assets/javascripts/set_status_modal/utils.js
+++ b/app/assets/javascripts/set_status_modal/utils.js
@@ -1,7 +1,4 @@
-export const AVAILABILITY_STATUS = {
- BUSY: 'busy',
- NOT_SET: 'not_set',
-};
+import { AVAILABILITY_STATUS } from './constants';
export const isUserBusy = (status = '') =>
Boolean(status.length && status.toLowerCase().trim() === AVAILABILITY_STATUS.BUSY);
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index a94dd128a1a..4408ebb881b 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -23,6 +23,10 @@ export default {
required: false,
default: false,
},
+ editable: {
+ type: Boolean,
+ required: true,
+ },
},
computed: {
assigneesText() {
@@ -43,7 +47,7 @@ export default {
data-testid="none"
>
<span> {{ __('None') }}</span>
- <template v-if="signedIn">
+ <template v-if="signedIn && editable">
<span class="gl-ml-2">-</span>
<gl-button
data-testid="assign-yourself"
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 5c432ca0e03..26fda2a823c 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -72,6 +72,10 @@ export default {
type: Boolean,
required: true,
},
+ editable: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -252,6 +256,7 @@ export default {
:users="assignees"
:issuable-type="issuableType"
:signed-in="signedIn"
+ :editable="editable"
@assign-self="assignSelf"
@expand-widget="expandWidget"
/>
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
index 336c291d4f1..c44ce8b0057 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -88,10 +88,7 @@ export default {
.then(
({
data: {
- issuableSetConfidential: {
- issuable: { confidential },
- errors,
- },
+ issuableSetConfidential: { errors },
},
}) => {
if (errors.length) {
@@ -99,7 +96,7 @@ export default {
message: errors[0],
});
} else {
- this.$emit('closeForm', { confidential });
+ this.$emit('closeForm');
}
},
)
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
index eec083f23f3..f234c5ea3c9 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -95,10 +95,10 @@ export default {
confidentialWidget.setConfidentiality = null;
},
methods: {
- closeForm({ confidential } = {}) {
+ closeForm() {
this.$refs.editable.collapse();
this.$el.dispatchEvent(hideDropdownEvent);
- this.$emit('closeForm', { confidential });
+ this.$emit('closeForm');
},
// synchronizing the quick action with the sidebar widget
// this is a temporary solution until we have confidentiality real-time updates
diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
index 8528ad56ddb..fd652583f76 100644
--- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
+++ b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
@@ -16,7 +16,7 @@ export default {
<template>
<copyable-field
- data-qa-selector="copy-forward-email"
+ data-testid="copy-forward-email"
:name="s__('RightSidebar|Issue email')"
:clipboard-tooltip-text="s__('RightSidebar|Copy email address')"
:value="issueEmailAddress"
diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
index aeaac76cff4..9c41db98c63 100644
--- a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
+++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
@@ -62,7 +62,7 @@ export default {
v-for="status in $options.STATUS_LIST"
:key="status"
data-testid="status-dropdown-item"
- :is-check-item="true"
+ is-check-item
:is-checked="status === value"
@click="$emit('input', status)"
>
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue
index 65b51169420..c9e651370f9 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue
@@ -1,10 +1,10 @@
<script>
import { GlSprintf } from '@gitlab/ui';
-import editFormButtons from './edit_form_buttons.vue';
+import EditFormButtons from './edit_form_buttons.vue';
export default {
components: {
- editFormButtons,
+ EditFormButtons,
GlSprintf,
},
props: {
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 5f1808ff4da..286bd50f6dd 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -6,7 +6,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createFlash from '~/flash';
import eventHub from '~/sidebar/event_hub';
import toast from '~/vue_shared/plugins/global_toast';
-import editForm from './edit_form.vue';
+import EditForm from './edit_form.vue';
export default {
issue: 'issue',
@@ -23,7 +23,7 @@ export default {
displayText: __('Unlocked'),
},
components: {
- editForm,
+ EditForm,
GlIcon,
},
directives: {
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index b8804de653f..2f25c2fd4b0 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
-import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
export default {
directives: {
@@ -11,7 +11,7 @@ export default {
GlButton,
GlIcon,
GlLoadingIcon,
- userAvatarImage,
+ UserAvatarImage,
},
props: {
loading: {
@@ -27,7 +27,7 @@ export default {
numberOfLessParticipants: {
type: Number,
required: false,
- default: 7,
+ default: 8,
},
showParticipantLabel: {
type: Boolean,
@@ -123,7 +123,7 @@ export default {
:size="24"
:tooltip-text="participant.name"
:img-alt="participant.name"
- css-classes="avatar-inline"
+ css-classes="gl-mr-0!"
tooltip-placement="bottom"
/>
</a>
diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
index a09138a708b..46a04725a49 100644
--- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
+++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
@@ -49,6 +49,9 @@ export default {
error,
});
},
+ context: {
+ isSingleRequest: true,
+ },
},
},
computed: {
@@ -68,7 +71,7 @@ export default {
<participants
:loading="isLoading"
:participants="participants"
- :number-of-less-participants="7"
+ :number-of-less-participants="8"
:lazy="false"
class="block participants"
@toggleSidebar="toggleSidebar"
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
index bf4ba715f85..a562df4ecd6 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
@@ -179,7 +179,7 @@ export default {
v-for="option in severitiesList"
:key="option.value"
data-testid="severityDropdownItem"
- :is-check-item="true"
+ is-check-item
:is-checked="option.value === severity"
@click="updateSeverity(option.value)"
>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 3d8a2cd847c..6c615109bb8 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -9,6 +9,8 @@ import {
GlLoadingIcon,
GlIcon,
GlTooltipDirective,
+ GlPopover,
+ GlButton,
} from '@gitlab/ui';
import { kebabCase, snakeCase } from 'lodash';
import createFlash from '~/flash';
@@ -17,6 +19,7 @@ import { IssuableType } from '~/issues/constants';
import { timeFor } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
dropdowni18nText,
Tracking,
@@ -47,7 +50,10 @@ export default {
GlSearchBoxByType,
GlIcon,
GlLoadingIcon,
+ GlPopover,
+ GlButton,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
isClassicSidebar: {
default: false,
@@ -66,6 +72,7 @@ export default {
},
},
},
+
props: {
issuableAttribute: {
type: String,
@@ -111,6 +118,10 @@ export default {
};
},
update(data) {
+ if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) {
+ this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic;
+ }
+
return data?.workspace?.issuable.attribute;
},
error(error) {
@@ -179,6 +190,8 @@ export default {
updating: false,
selectedTitle: null,
currentAttribute: null,
+ hasCurrentAttribute: false,
+ editConfirmation: false,
attributesList: [],
tracking: {
event: Tracking.editEvent,
@@ -228,6 +241,15 @@ export default {
snake: snakeCase(this.issuableAttribute),
};
},
+ shouldShowConfirmationPopover() {
+ if (!this.glFeatures?.epicWidgetEditConfirmation) {
+ return false;
+ }
+
+ return this.isEpic && this.currentAttribute === null && this.hasCurrentAttribute
+ ? !this.editConfirmation
+ : false;
+ },
},
methods: {
updateAttribute(attributeId) {
@@ -299,6 +321,17 @@ export default {
setFocus() {
this.$refs.search.focusInput();
},
+ handlePopoverClose() {
+ this.$refs.popover.$emit('close');
+ },
+ handlePopoverConfirm(cb) {
+ this.editConfirmation = true;
+ this.handlePopoverClose();
+ setTimeout(cb, 0);
+ },
+ handleEditConfirmation() {
+ this.$refs.popover.$emit('open');
+ },
},
};
</script>
@@ -308,10 +341,13 @@ export default {
ref="editable"
:title="attributeTypeTitle"
:data-testid="`${formatIssuableAttribute.kebab}-edit`"
+ :button-id="`${formatIssuableAttribute.kebab}-edit`"
:tracking="tracking"
+ :should-show-confirmation-popover="shouldShowConfirmationPopover"
:loading="updating || loading"
@open="handleOpen"
@close="handleClose"
+ @edit-confirm="handleEditConfirmation"
>
<template #collapsed>
<slot name="value-collapsed" :current-attribute="currentAttribute">
@@ -332,6 +368,10 @@ export default {
:class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
>
<span v-if="updating">{{ selectedTitle }}</span>
+ <template v-else-if="!currentAttribute && hasCurrentAttribute">
+ <gl-icon name="warning" class="gl-text-orange-500" />
+ <span class="gl-text-gray-500">{{ i18n.noPermissionToView }}</span>
+ </template>
<span v-else-if="!currentAttribute" class="gl-text-gray-500">
{{ $options.i18n.none }}
</span>
@@ -344,6 +384,7 @@ export default {
>
<gl-link
v-gl-tooltip="tooltipText"
+ class="gl-reset-color gl-hover-text-blue-800"
:href="attributeUrl"
:data-qa-selector="`${formatIssuableAttribute.snake}_link`"
>
@@ -353,7 +394,40 @@ export default {
</slot>
</div>
</template>
- <template #default>
+ <template v-if="shouldShowConfirmationPopover" #default="{ toggle }">
+ <gl-popover
+ ref="popover"
+ :target="`${formatIssuableAttribute.kebab}-edit`"
+ placement="bottomleft"
+ boundary="viewport"
+ triggers="click"
+ >
+ <div class="gl-mb-4 gl-font-base">
+ {{ i18n.editConfirmation }}
+ </div>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-button
+ size="small"
+ variant="confirm"
+ category="primary"
+ data-testid="confirm-edit-cta"
+ @click.prevent="() => handlePopoverConfirm(toggle)"
+ >{{ i18n.editConfirmationCta }}</gl-button
+ >
+ <gl-button
+ class="gl-ml-auto"
+ size="small"
+ name="cancel"
+ variant="default"
+ category="primary"
+ data-testid="confirm-edit-cancel"
+ @click.prevent="handlePopoverClose"
+ >{{ i18n.editConfirmationCancel }}</gl-button
+ >
+ </div>
+ </gl-popover>
+ </template>
+ <template v-else #default>
<gl-dropdown
ref="newDropdown"
lazy
@@ -368,7 +442,7 @@ export default {
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
:data-testid="`no-${formatIssuableAttribute.kebab}-item`"
- :is-check-item="true"
+ is-check-item
:is-checked="isAttributeChecked($options.noAttributeId)"
@click="updateAttribute($options.noAttributeId)"
>
@@ -395,7 +469,7 @@ export default {
<gl-dropdown-item
v-for="attrItem in attributesList"
:key="attrItem.id"
- :is-check-item="true"
+ is-check-item
:is-checked="isAttributeChecked(attrItem.id)"
:data-testid="`${formatIssuableAttribute.kebab}-items`"
@click="updateAttribute(attrItem.id)"
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index 7551b181a58..cc88812c7b0 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -14,6 +14,11 @@ export default {
},
},
props: {
+ buttonId: {
+ type: String,
+ required: false,
+ default: '',
+ },
title: {
type: String,
required: false,
@@ -48,6 +53,11 @@ export default {
required: false,
default: true,
},
+ shouldShowConfirmationPopover: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -97,6 +107,11 @@ export default {
window.removeEventListener('keyup', this.collapseOnEscape);
},
toggle({ emitEvent = true } = {}) {
+ if (this.shouldShowConfirmationPopover) {
+ this.$emit('edit-confirm');
+ return;
+ }
+
if (this.edit) {
this.collapse({ emitEvent });
} else {
@@ -132,6 +147,7 @@ export default {
<slot name="collapsed-right"></slot>
<gl-button
v-if="canUpdate && !initialLoading && canEdit"
+ :id="buttonId"
category="tertiary"
size="small"
class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2 shortcut-sidebar-dropdown-toggle"
@@ -151,7 +167,7 @@ export default {
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
- <slot :edit="edit"></slot>
+ <slot :edit="edit" :toggle="toggle"></slot>
</div>
</template>
</div>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
index 7662d645dd9..e5bee4df9b8 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -181,18 +181,18 @@ export default {
</script>
<template>
- <li v-if="isMergeRequest" class="gl-new-dropdown-item">
- <button type="button" class="dropdown-item" @click="toggleSubscribed">
- <span class="gl-new-dropdown-item-text-wrapper">
- <template v-if="subscribed">
- {{ __('Turn off notifications') }}
- </template>
- <template v-else>
- {{ __('Turn on notifications') }}
- </template>
- </span>
- </button>
- </li>
+ <div v-if="isMergeRequest" class="gl-new-dropdown-item">
+ <div class="gl-px-5 gl-pb-2 gl-pt-1">
+ <gl-toggle
+ :value="subscribed"
+ :label="__('Notifications')"
+ class="merge-request-notification-toggle"
+ label-position="left"
+ data-testid="notifications-toggle"
+ @change="toggleSubscribed"
+ />
+ </div>
+ </div>
<sidebar-editable-item
v-else
ref="editable"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js b/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js
deleted file mode 100644
index 70177d84b1b..00000000000
--- a/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import produce from 'immer';
-
-export function removeTimelogFromStore(store, deletedTimelogId, query, variables) {
- const sourceData = store.readQuery({
- query,
- variables,
- });
-
- const data = produce(sourceData, (draftData) => {
- draftData.issuable.timelogs.nodes = draftData.issuable.timelogs.nodes.filter(
- ({ id }) => id !== deletedTimelogId,
- );
- });
-
- store.writeQuery({
- query,
- variables,
- data,
- });
-}
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql b/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql
index 17bbad1acb1..6e916893b5a 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql
+++ b/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql
@@ -1,5 +1,17 @@
+#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql"
+#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql"
+
mutation deleteTimelog($input: TimelogDeleteInput!) {
timelogDelete(input: $input) {
errors
+ timelog {
+ id
+ issue {
+ ...IssueTimeTrackingFragment
+ }
+ mergeRequest {
+ ...MergeRequestTimeTrackingFragment
+ }
+ }
}
}
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
index 79ef5a32474..d751816bd94 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -7,7 +7,6 @@ import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_ut
import { __, s__ } from '~/locale';
import { timelogQueries } from '~/sidebar/constants';
import deleteTimelogMutation from './graphql/mutations/delete_timelog.mutation.graphql';
-import { removeTimelogFromStore } from './graphql/cache_update';
const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
@@ -99,14 +98,6 @@ export default {
.mutate({
mutation: deleteTimelogMutation,
variables: { input: { id: timelogId } },
- update: (store) => {
- removeTimelogFromStore(
- store,
- timelogId,
- timelogQueries[this.issuableType].query,
- this.getQueryVariables(),
- );
- },
})
.then(({ data }) => {
if (data.timelogDelete?.errors?.length) {
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index e39d9f9fb49..13981c477c6 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -1,8 +1,16 @@
<script>
-import { GlIcon, GlLink, GlModal, GlButton, GlModalDirective, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlLink,
+ GlModal,
+ GlButton,
+ GlModalDirective,
+ GlLoadingIcon,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
import { s__, __ } from '~/locale';
-import { timeTrackingQueries } from '~/sidebar/constants';
+import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '~/sidebar/constants';
import eventHub from '../../event_hub';
import TimeTrackingCollapsedState from './collapsed_state.vue';
@@ -31,6 +39,7 @@ export default {
},
directives: {
GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
inject: {
issuableType: {
@@ -162,6 +171,12 @@ export default {
this.issuableId
);
},
+ timeTrackingIconTitle() {
+ return this.showHelpState ? '' : HOW_TO_TRACK_TIME;
+ },
+ timeTrackingIconName() {
+ return this.showHelpState ? 'close' : 'question-o';
+ },
},
watch: {
/**
@@ -188,11 +203,7 @@ export default {
</script>
<template>
- <div
- v-cloak
- class="time-tracker time-tracking-component-wrap sidebar-help-wrap"
- data-testid="time-tracker"
- >
+ <div v-cloak class="time-tracker sidebar-help-wrap" data-testid="time-tracker">
<time-tracking-collapsed-state
v-if="showCollapsed"
:show-comparison-state="showComparisonState"
@@ -216,7 +227,12 @@ export default {
class="gl-ml-auto"
@click="toggleHelpState(!showHelpState)"
>
- <gl-icon :name="showHelpState ? 'close' : 'question-o'" class="gl-text-gray-900!" />
+ <gl-icon
+ v-gl-tooltip.left
+ :title="timeTrackingIconTitle"
+ :name="timeTrackingIconName"
+ class="gl-text-gray-900!"
+ />
</gl-button>
</div>
<div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed">
@@ -252,7 +268,6 @@ export default {
size="lg"
:title="__('Time tracking report')"
:hide-footer="true"
- @hide="refresh"
>
<time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" />
</gl-modal>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
index 482b9343e70..42e16aae312 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
@@ -6,6 +6,7 @@ import { __, sprintf } from '~/locale';
import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants';
import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils';
import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Tracking from '~/tracking';
const trackingMixin = Tracking.mixin();
@@ -19,7 +20,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [trackingMixin],
+ mixins: [glFeatureFlagsMixin(), trackingMixin],
inject: {
isClassicSidebar: {
default: false,
@@ -81,6 +82,9 @@ export default {
},
},
computed: {
+ isMergeRequest() {
+ return this.glFeatures.movedMrSidebar && this.issuableType === 'merge_request';
+ },
todoIdQuery() {
return todoQueries[this.issuableType].query;
},
@@ -183,12 +187,12 @@ export default {
:issuable-id="issuableId"
:is-todo="hasTodo"
:loading="isLoading"
- size="small"
+ :size="isMergeRequest ? 'medium' : 'small'"
class="hide-collapsed"
@click.stop.prevent="toggleTodo"
/>
<gl-button
- v-if="isClassicSidebar"
+ v-if="isClassicSidebar && !isMergeRequest"
v-gl-tooltip.left.viewport
:title="tootltipTitle"
category="tertiary"
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 989dc574bc3..60cb4cff727 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,4 +1,4 @@
-import { s__, sprintf } from '~/locale';
+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';
@@ -313,8 +313,26 @@ export function dropdowni18nText(issuableAttribute, issuableType) {
),
{ issuableAttribute, issuableType },
),
+ noPermissionToView: sprintf(
+ s__("DropdownWidget|You don't have permission to view this %{issuableAttribute}."),
+ { issuableAttribute },
+ ),
+ editConfirmation: sprintf(
+ s__(
+ 'DropdownWidget|You do not have permission to view the currently assigned %{issuableAttribute} and will not be able to choose it again if you reassign it.',
+ ),
+ {
+ issuableAttribute,
+ },
+ ),
+ editConfirmationCta: sprintf(s__('DropdownWidget|Edit %{issuableAttribute}'), {
+ issuableAttribute,
+ }),
+ editConfirmationCancel: s__('DropdownWidget|Cancel'),
};
}
export const escalationStatusQuery = getEscalationStatusQuery;
export const escalationStatusMutation = updateEscalationStatusMutation;
+
+export const HOW_TO_TRACK_TIME = __('How to track time');
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
deleted file mode 100644
index 127e3a3c610..00000000000
--- a/app/assets/javascripts/sidebar/graphql.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import produce from 'immer';
-import VueApollo from 'vue-apollo';
-import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
-import createDefaultClient from '~/lib/graphql';
-import { temporaryConfig, resolvers as workItemResolvers } from '~/work_items/graphql/provider';
-
-const resolvers = {
- Mutation: {
- updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
- const sourceData = cache.readQuery({ query: getIssueStateQuery });
- const data = produce(sourceData, (draftData) => {
- draftData.issueState = { issueType, isDirty };
- });
- cache.writeQuery({ query: getIssueStateQuery, data });
- },
- ...workItemResolvers.Mutation,
- },
-};
-
-export const defaultClient = createDefaultClient(
- resolvers,
- // should be removed with the rollout of work item assignees FF
- // https://gitlab.com/gitlab-org/gitlab/-/issues/363030
- temporaryConfig,
-);
-
-export const apolloProvider = new VueApollo({
- defaultClient,
-});
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index 2aacce2fb00..cc5de5e4083 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { IssuableType } from '~/issues/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
-import timeTracker from './components/time_tracking/time_tracker.vue';
+import TimeTracker from './components/time_tracking/time_tracker.vue';
export default class SidebarMilestone {
constructor() {
@@ -23,13 +23,13 @@ export default class SidebarMilestone {
el,
name: 'SidebarMilestoneRoot',
components: {
- timeTracker,
+ TimeTracker,
},
provide: {
issuableType: IssuableType.Milestone,
},
render: (createElement) =>
- createElement('timeTracker', {
+ createElement('time-tracker', {
props: {
limitToHours: parseBoolean(limitToHours),
issuableIid: iid.toString(),
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index fec4d0e346d..1cb3c30b9e0 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -22,7 +22,7 @@ import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
-import { apolloProvider } from '~/sidebar/graphql';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
@@ -39,7 +39,6 @@ import SidebarTimeTracking from './components/time_tracking/sidebar_time_trackin
import { IssuableAttributeType } from './constants';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import CrmContacts from './components/crm_contacts/crm_contacts.vue';
-import SidebarEventHub from './event_hub';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -163,6 +162,7 @@ function mountAssigneesComponent() {
issuableType,
issuableId: id,
allowMultipleAssignees: !el.dataset.maxAssignees,
+ editable,
},
scopedSlots: {
collapsed: ({ users }) =>
@@ -360,13 +360,6 @@ function mountConfidentialComponent() {
? IssuableType.Issue
: IssuableType.MergeRequest,
},
- on: {
- closeForm({ confidential }) {
- if (confidential !== undefined) {
- SidebarEventHub.$emit('confidentialityUpdated', confidential);
- }
- },
- },
}),
});
}
diff --git a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
index f4d0e9b5deb..41d45b486e8 100644
--- a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
@@ -1,12 +1,12 @@
+#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql"
+
query issueTimeTracking($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
id
issuable: issue(iid: $iid) {
- id
+ ...IssueTimeTrackingFragment
humanTimeEstimate
- humanTotalTimeSpent
timeEstimate
- totalTimeSpent
}
}
}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
index 5d05cb2f34c..12ef78a6453 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
@@ -1,12 +1,12 @@
+#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql"
+
query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
id
issuable: mergeRequest(iid: $iid) {
- id
+ ...MergeRequestTimeTrackingFragment
humanTimeEstimate
- humanTotalTimeSpent
timeEstimate
- totalTimeSpent
}
}
}
diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue
index ee8b00c1f5d..853293e5eb6 100644
--- a/app/assets/javascripts/snippets/components/show.vue
+++ b/app/assets/javascripts/snippets/components/show.vue
@@ -6,7 +6,7 @@ import {
SNIPPET_MEASURE_BLOBS_CONTENT,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
-import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants';
+import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants';
import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
import { getSnippetMixin } from '../mixins/snippets';
@@ -31,7 +31,7 @@ export default {
mixins: [getSnippetMixin],
computed: {
embeddable() {
- return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC;
+ return this.snippet.visibilityLevel === VISIBILITY_LEVEL_PUBLIC_STRING;
},
canBeCloned() {
return Boolean(this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo);
diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js
index 2a9ecbc27dc..84a940ed1f8 100644
--- a/app/assets/javascripts/snippets/constants.js
+++ b/app/assets/javascripts/snippets/constants.js
@@ -1,22 +1,23 @@
import { __ } from '~/locale';
-
-export const SNIPPET_VISIBILITY_PRIVATE = 'private';
-export const SNIPPET_VISIBILITY_INTERNAL = 'internal';
-export const SNIPPET_VISIBILITY_PUBLIC = 'public';
+import {
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ VISIBILITY_LEVEL_INTERNAL_STRING,
+ VISIBILITY_LEVEL_PUBLIC_STRING,
+} from '~/visibility_level/constants';
export const SNIPPET_VISIBILITY = {
- [SNIPPET_VISIBILITY_PRIVATE]: {
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: {
label: __('Private'),
icon: 'lock',
description: __('The snippet is visible only to me.'),
description_project: __('The snippet is visible only to project members.'),
},
- [SNIPPET_VISIBILITY_INTERNAL]: {
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: {
label: __('Internal'),
icon: 'shield',
description: __('The snippet is visible to any logged in user except external users.'),
},
- [SNIPPET_VISIBILITY_PUBLIC]: {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: {
label: __('Public'),
icon: 'earth',
description: __('The snippet can be accessed without any authentication.'),
@@ -34,11 +35,6 @@ export const SNIPPET_BLOB_ACTION_DELETE = 'delete';
export const SNIPPET_MAX_BLOBS = 10;
-export const SNIPPET_LEVELS_MAP = {
- 0: SNIPPET_VISIBILITY_PRIVATE,
- 10: SNIPPET_VISIBILITY_INTERNAL,
- 20: SNIPPET_VISIBILITY_PUBLIC,
-};
export const SNIPPET_LEVELS_RESTRICTED = __(
'Other visibility settings have been disabled by the administrator.',
);
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index 21f38c4d8c9..89dd5e586fb 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -2,7 +2,10 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants';
+import {
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ VISIBILITY_LEVELS_INTEGER_TO_STRING,
+} from '~/visibility_level/constants';
import Translate from '~/vue_shared/translate';
Vue.use(VueApollo);
@@ -36,7 +39,8 @@ export default function appFactory(el, Component) {
apolloProvider,
provide: {
visibilityLevels: JSON.parse(visibilityLevels),
- selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE,
+ selectedLevel:
+ VISIBILITY_LEVELS_INTEGER_TO_STRING[selectedLevel] ?? VISIBILITY_LEVEL_PRIVATE_STRING,
multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset,
reportAbusePath,
canReportSpam,
diff --git a/app/assets/javascripts/snippets/utils/blob.js b/app/assets/javascripts/snippets/utils/blob.js
index 2a3f590a803..a228d6111ce 100644
--- a/app/assets/javascripts/snippets/utils/blob.js
+++ b/app/assets/javascripts/snippets/utils/blob.js
@@ -1,12 +1,12 @@
import { uniqueId } from 'lodash';
import { SNIPPET_MARK_BLOBS_CONTENT, SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
+import { VISIBILITY_LEVELS_INTEGER_TO_STRING } from '~/visibility_level/constants';
import {
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
SNIPPET_BLOB_ACTION_DELETE,
- SNIPPET_LEVELS_MAP,
SNIPPET_VISIBILITY,
} from '../constants';
@@ -72,7 +72,7 @@ export const diffAll = (blobs, origBlobs) => {
export const defaultSnippetVisibilityLevels = (arr) => {
if (Array.isArray(arr)) {
return arr.map((l) => {
- const translatedLevel = SNIPPET_LEVELS_MAP[l];
+ const translatedLevel = VISIBILITY_LEVELS_INTEGER_TO_STRING[l];
return {
value: translatedLevel,
...SNIPPET_VISIBILITY[translatedLevel],
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue
index 4e4ef49b1c6..df114c27908 100644
--- a/app/assets/javascripts/surveys/merge_request_experience/app.vue
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue
@@ -19,6 +19,8 @@ const steps = [
},
];
+const MR_RENDER_LS_KEY = 'mr_survey_rendered';
+
export default {
name: 'MergeRequestExperienceSurveyApp',
components: {
@@ -68,9 +70,20 @@ export default {
onQueryLoaded({ shouldShowCallout }) {
this.visible = shouldShowCallout;
if (!this.visible) this.$emit('close');
+ else if (!localStorage?.getItem(MR_RENDER_LS_KEY)) {
+ this.track('survey:mr_experience', {
+ label: 'render',
+ extra: {
+ accountAge: this.accountAge,
+ },
+ });
+ localStorage?.setItem(MR_RENDER_LS_KEY, '1');
+ }
},
onRate(event) {
+ this.$refs.dismisser?.dismiss();
this.$emit('rate');
+ localStorage?.removeItem(MR_RENDER_LS_KEY);
this.track('survey:mr_experience', {
label: this.step.label,
value: event,
@@ -87,8 +100,18 @@ export default {
},
handleKeyup(e) {
if (e.key !== 'Escape') return;
- this.$emit('close');
+ this.dismiss();
+ },
+ dismiss() {
this.$refs.dismisser?.dismiss();
+ this.$emit('close');
+ this.track('survey:mr_experience', {
+ label: 'dismiss',
+ extra: {
+ accountAge: this.accountAge,
+ },
+ });
+ localStorage?.removeItem(MR_RENDER_LS_KEY);
},
},
};
@@ -100,79 +123,71 @@ export default {
feature-name="mr_experience_survey"
@queryResult.once="onQueryLoaded"
>
- <template #default="{ dismiss }">
- <aside
- class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5"
- :aria-label="$options.i18n.survey"
- >
- <transition name="survey-slide-up">
+ <aside
+ class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5"
+ :aria-label="$options.i18n.survey"
+ >
+ <transition name="survey-slide-up">
+ <div
+ v-if="visible"
+ class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
+ >
+ <gl-button
+ v-tooltip="$options.i18n.close"
+ :aria-label="$options.i18n.close"
+ variant="default"
+ category="tertiary"
+ class="gl-top-4 gl-right-3 gl-absolute"
+ icon="close"
+ @click="dismiss"
+ />
<div
- v-if="visible"
- class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
+ v-if="stepIndex === 0"
+ class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
+ role="note"
>
- <gl-button
- v-tooltip="$options.i18n.close"
- :aria-label="$options.i18n.close"
- variant="default"
- category="tertiary"
- class="gl-top-4 gl-right-3 gl-absolute"
- icon="close"
- @click="
- dismiss();
- $emit('close');
- "
- />
- <div
- v-if="stepIndex === 0"
- class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
- role="note"
- >
- <p class="gl-m-0">
- <gl-sprintf :message="$options.i18n.legal">
- <template #link="{ content }">
- <a
- class="gl-text-decoration-underline gl-text-gray-500"
- href="https://about.gitlab.com/privacy/"
- target="_blank"
- rel="noreferrer nofollow"
- v-text="content"
- ></a>
- </template>
- </gl-sprintf>
- </p>
- </div>
- <div class="gl-relative">
- <div class="gl-absolute">
- <div
- v-safe-html="$options.gitlabLogo"
- aria-hidden="true"
- class="mr-experience-survey-logo"
- ></div>
- </div>
+ <p class="gl-m-0">
+ <gl-sprintf :message="$options.i18n.legal">
+ <template #link="{ content }">
+ <a
+ class="gl-text-decoration-underline gl-text-gray-500"
+ href="https://about.gitlab.com/privacy/"
+ target="_blank"
+ rel="noreferrer nofollow"
+ v-text="content"
+ ></a>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <div class="gl-relative">
+ <div class="gl-absolute">
+ <div
+ v-safe-html="$options.gitlabLogo"
+ aria-hidden="true"
+ class="mr-experience-survey-logo"
+ ></div>
</div>
- <section v-if="step">
- <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
- <gl-sprintf :message="step.question">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <satisfaction-rate
- aria-labelledby="mr_survey_question"
- class="gl-mt-5"
- @rate="
- dismiss();
- onRate($event);
- "
- />
- </section>
- <section v-else class="gl-px-7">
- {{ $options.i18n.thanks }}
- </section>
</div>
- </transition>
- </aside>
- </template>
+ <section v-if="step">
+ <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
+ <gl-sprintf :message="step.question">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <satisfaction-rate
+ aria-labelledby="mr_survey_question"
+ class="gl-mt-5"
+ @rate="onRate"
+ />
+ </section>
+ <section v-else class="gl-px-7">
+ {{ $options.i18n.thanks }}
+ </section>
+ </div>
+ </transition>
+ </aside>
</user-callout-dismisser>
</template>
diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue
index de8cd856bf7..363a9d58d65 100644
--- a/app/assets/javascripts/token_access/components/token_access.vue
+++ b/app/assets/javascripts/token_access/components/token_access.vue
@@ -1,7 +1,16 @@
<script>
-import { GlButton, GlCard, GlFormInput, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+import {
+ GlButton,
+ GlCard,
+ GlFormInput,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ GlToggle,
+} from '@gitlab/ui';
import createFlash from '~/flash';
import { __, s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql';
import removeProjectCIJobTokenScopeMutation from '../graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql';
import updateCIJobTokenScopeMutation from '../graphql/mutations/update_ci_job_token_scope.mutation.graphql';
@@ -13,7 +22,7 @@ export default {
i18n: {
toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'),
toggleHelpText: s__(
- `CICD|Select projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable.`,
+ `CICD|Select the projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more.%{linkEnd}`,
),
cardHeaderTitle: s__('CICD|Add an existing project to the scope'),
addProject: __('Add project'),
@@ -26,7 +35,9 @@ export default {
GlButton,
GlCard,
GlFormInput,
+ GlLink,
GlLoadingIcon,
+ GlSprintf,
GlToggle,
TokenProjectsTable,
},
@@ -76,6 +87,9 @@ export default {
isProjectPathEmpty() {
return this.targetProjectPath === '';
},
+ ciJobTokenHelpPage() {
+ return helpPagePath('ci/jobs/ci_job_token');
+ },
},
methods: {
async updateCIJobTokenScope() {
@@ -99,10 +113,6 @@ export default {
}
} catch (error) {
createFlash({ message: error });
- } finally {
- if (this.jobTokenScopeEnabled) {
- this.getProjects();
- }
}
},
async addProject() {
@@ -172,10 +182,20 @@ export default {
<gl-toggle
v-model="jobTokenScopeEnabled"
:label="$options.i18n.toggleLabelTitle"
- :help="$options.i18n.toggleHelpText"
@change="updateCIJobTokenScope"
- />
- <div v-if="jobTokenScopeEnabled" data-testid="token-section">
+ >
+ <template #help>
+ <gl-sprintf :message="$options.i18n.toggleHelpText">
+ <template #link="{ content }">
+ <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-toggle>
+
+ <div data-testid="token-section">
<gl-card class="gl-mt-5">
<template #header>
<h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5>
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index c2892fb8dac..ee5d6a22fc3 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -46,6 +46,7 @@ const populateUserInfo = (user) => {
pronouns: userData.pronouns,
localTime: userData.local_time,
isFollowed: userData.is_followed,
+ state: userData.state,
loaded: true,
});
}
diff --git a/app/assets/javascripts/validators/input_validator.js b/app/assets/javascripts/validators/input_validator.js
index f37373977b8..b799976a0ba 100644
--- a/app/assets/javascripts/validators/input_validator.js
+++ b/app/assets/javascripts/validators/input_validator.js
@@ -19,6 +19,7 @@ export default class InputValidator {
setValidationMessage() {
if (this.invalidInput) {
this.inputDomElement.setCustomValidity(this.errorMessage);
+ // eslint-disable-next-line no-unsanitized/property
this.inputErrorMessage.innerHTML = this.errorMessage;
} else {
this.resetValidationMessage();
@@ -28,6 +29,7 @@ export default class InputValidator {
resetValidationMessage() {
if (this.inputDomElement.validationMessage === this.errorMessage) {
this.inputDomElement.setCustomValidity('');
+ // eslint-disable-next-line no-unsanitized/property
this.inputErrorMessage.innerHTML = this.inputDomElement.title;
}
}
diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js
index 65f0eceae55..77736fb6ef5 100644
--- a/app/assets/javascripts/visibility_level/constants.js
+++ b/app/assets/javascripts/visibility_level/constants.js
@@ -1,10 +1,20 @@
-export const VISIBILITY_LEVEL_PRIVATE = 'private';
-export const VISIBILITY_LEVEL_INTERNAL = 'internal';
-export const VISIBILITY_LEVEL_PUBLIC = 'public';
+export const VISIBILITY_LEVEL_PRIVATE_STRING = 'private';
+export const VISIBILITY_LEVEL_INTERNAL_STRING = 'internal';
+export const VISIBILITY_LEVEL_PUBLIC_STRING = 'public';
+
+export const VISIBILITY_LEVEL_PRIVATE_INTEGER = 0;
+export const VISIBILITY_LEVEL_INTERNAL_INTEGER = 10;
+export const VISIBILITY_LEVEL_PUBLIC_INTEGER = 20;
// Matches `lib/gitlab/visibility_level.rb`
-export const VISIBILITY_LEVELS_ENUM = {
- [VISIBILITY_LEVEL_PRIVATE]: 0,
- [VISIBILITY_LEVEL_INTERNAL]: 10,
- [VISIBILITY_LEVEL_PUBLIC]: 20,
+export const VISIBILITY_LEVELS_STRING_TO_INTEGER = {
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: VISIBILITY_LEVEL_PRIVATE_INTEGER,
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: VISIBILITY_LEVEL_INTERNAL_INTEGER,
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: VISIBILITY_LEVEL_PUBLIC_INTEGER,
+};
+
+export const VISIBILITY_LEVELS_INTEGER_TO_STRING = {
+ [VISIBILITY_LEVEL_PRIVATE_INTEGER]: VISIBILITY_LEVEL_PRIVATE_STRING,
+ [VISIBILITY_LEVEL_INTERNAL_INTEGER]: VISIBILITY_LEVEL_INTERNAL_STRING,
+ [VISIBILITY_LEVEL_PUBLIC_INTEGER]: VISIBILITY_LEVEL_PUBLIC_STRING,
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
index 38f40e8a3c8..30a0e7c383c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
@@ -63,6 +63,12 @@ export default {
return btn.tooltipText;
},
+ actionButtonQaSelector(btn) {
+ if (btn.dataQaSelector) {
+ return btn.dataQaSelector;
+ }
+ return 'mr_widget_extension_actions_button';
+ },
},
};
</script>
@@ -105,7 +111,7 @@ export default {
:target="btn.target"
:class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
:data-clipboard-text="btn.dataClipboardText"
- :data-qa-selector="btn.dataQaSelector"
+ :data-qa-selector="actionButtonQaSelector(btn)"
:data-method="btn.dataMethod"
:icon="btn.icon"
:data-testid="btn.testId || 'extension-actions-button'"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
index 254b280bf14..f377a185879 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlLink } from '@gitlab/ui';
import { escape } from 'lodash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { n__, s__, sprintf } from '~/locale';
@@ -9,6 +9,7 @@ const mergeCommitCount = s__('mrWidgetCommitsAdded|%{strongStart}1%{strongEnd} m
export default {
components: {
GlSprintf,
+ GlLink,
},
mixins: [glFeatureFlagMixin()],
props: {
@@ -40,6 +41,11 @@ export default {
required: false,
default: '',
},
+ mergeCommitPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
isMerged() {
@@ -124,7 +130,9 @@ export default {
</template>
</template>
<template #mergeCommitSha>
- <span class="label-branch">{{ mergeCommitSha }}</span>
+ <gl-link :href="mergeCommitPath" class="label-branch" data-testid="merge-commit-sha">{{
+ mergeCommitSha
+ }}</gl-link>
</template>
</gl-sprintf>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
index b1c4f7c5a7c..d7255eb6ad2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
@@ -103,7 +103,7 @@ export default {
<span v-if="approvalLeftMessage">{{ message }}</span>
<span v-else class="gl-font-weight-bold">{{ message }}</span>
<user-avatar-list
- class="gl-display-inline-block gl-vertical-align-middle"
+ class="gl-display-inline-block gl-vertical-align-middle gl-pt-1"
:img-size="24"
:items="approvers"
/>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
index e115710b5d1..30098f7619a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
@@ -74,16 +74,12 @@ export default {
<div class="js-deployment-info deployment-info">
<template v-if="hasDeploymentMeta">
<span>{{ deployedText }}</span>
- <tooltip-on-truncate
- :title="deployment.name"
- truncate-target="child"
- class="deploy-link label-truncate"
- >
+ <tooltip-on-truncate :title="deployment.name" truncate-target="child" class="label-truncate">
<gl-link
:href="deployment.url"
target="_blank"
rel="noopener noreferrer nofollow"
- class="js-deploy-meta gl-font-sm"
+ class="js-deploy-meta gl-font-sm gl-pb-1"
>
{{ deployment.name }}
</gl-link>
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 414c5bf9691..300e2a672cb 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
@@ -13,6 +13,7 @@ import Poll from '~/lib/utils/poll';
import { normalizeHeaders } from '~/lib/utils/common_utils';
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
import Actions from '../action_buttons.vue';
+import StateContainer from '../state_container.vue';
import StatusIcon from './status_icon.vue';
import ChildContent from './child_content.vue';
import { createTelemetryHub } from './telemetry';
@@ -36,6 +37,7 @@ export default {
ChildContent,
DynamicScroller,
DynamicScrollerItem,
+ StateContainer,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -307,19 +309,20 @@ export default {
</script>
<template>
- <section class="media-section" data-testid="widget-extension">
- <div
+ <section
+ class="media-section"
+ data-testid="widget-extension"
+ data-qa-selector="mr_widget_extension"
+ >
+ <state-container
+ :mr="mr"
+ :status="statusIconName"
+ :is-loading="isLoadingSummary"
:class="{ 'gl-cursor-pointer': isCollapsible }"
- class="media gl-p-5"
+ class="gl-p-5"
@mousedown="onRowMouseDown"
@mouseup="onRowMouseUp"
>
- <status-icon
- :level="1"
- :name="$options.label || $options.name"
- :is-loading="isLoadingSummary"
- :icon-name="statusIconName"
- />
<div
class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center"
data-testid="widget-extension-top-level"
@@ -352,12 +355,13 @@ export default {
:icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
category="tertiary"
data-testid="toggle-button"
+ data-qa-selector="toggle_button"
size="small"
@click="toggleCollapsed"
/>
</div>
</div>
- </div>
+ </state-container>
<div
v-if="!isCollapsed"
class="mr-widget-grouped-section gl-relative"
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
index 1eccc7de660..52c9f047b76 100644
--- 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
@@ -62,7 +62,9 @@ export default {
<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 v-if="data.icon" class="report-block-child-icon gl-display-flex">
+ <status-icon :icon-name="data.icon.name" :size="12" class="gl-m-auto" />
+ </div>
<div class="gl-w-full">
<div class="gl-display-flex gl-flex-nowrap">
<div class="gl-flex-wrap gl-display-flex gl-w-full">
@@ -109,6 +111,7 @@ export default {
:modal-id="modalId"
:level="3"
data-testid="child-content"
+ data-qa-selector="child_content"
@clickedAction="onClickedAction"
/>
</li>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
index dc748ba44f2..f9d0986d60d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
@@ -49,18 +49,28 @@ export default {
<div
:class="[
$options.EXTENSION_ICON_CLASS[iconName],
- { 'mr-widget-extension-icon gl-w-6': !isLoading && level === 1 },
+ { 'gl-w-6': !isLoading && level === 1 },
{ 'gl-p-2': isLoading || level === 1 },
]"
- class="gl-rounded-full gl-mr-3 gl-relative gl-p-2"
+ class="gl-mr-3 gl-p-2"
>
- <gl-loading-icon v-if="isLoading" size="sm" inline class="gl-display-block" />
- <gl-icon
- v-else
- :name="$options.EXTENSION_ICON_NAMES[iconName]"
- :size="size"
- :aria-label="iconAriaLabel"
- class="gl-display-block"
- />
+ <div
+ class="gl-rounded-full gl-relative gl-display-flex"
+ :class="{ 'mr-widget-extension-icon': !isLoading && level === 1 }"
+ >
+ <div class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto">
+ <div class="gl-display-flex gl-m-auto gl-translate-y-n50">
+ <gl-loading-icon v-if="isLoading" size="md" inline />
+ <gl-icon
+ v-else
+ :name="$options.EXTENSION_ICON_NAMES[iconName]"
+ :size="size"
+ :aria-label="iconAriaLabel"
+ :data-qa-selector="`status_${iconName}_icon`"
+ class="gl-display-block"
+ />
+ </div>
+ </div>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
index bc84459e298..d67ff11f297 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
@@ -24,7 +24,7 @@ const nonStandardEvents = {
},
issues: {
uniqueUser: {
- expand: ['i_testing_load_performance_widget_total'],
+ expand: ['i_testing_issues_widget_total'],
},
counter: {},
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
index 437342bf438..0c36e1ccd7f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
@@ -13,7 +13,7 @@ export default {
</script>
<template>
- <div class="circle-icon-container gl-mr-3 align-self-start">
+ <div class="circle-icon-container gl-mr-3 align-self-start gl-mt-2">
<gl-icon :name="name" :size="24" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 1e1a2049414..fe69e96bd87 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -9,11 +9,10 @@ import {
GlTooltipDirective,
GlSafeHtmlDirective,
} from '@gitlab/ui';
-import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline';
import { s__, n__ } from '~/locale';
-import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
-import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.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 { MT_MERGE_STRATEGY } from '../constants';
@@ -31,14 +30,11 @@ export default {
PipelineMiniGraph,
TimeAgoTooltip,
TooltipOnTruncate,
- LinkedPipelinesMiniList: () =>
- import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
- mixins: [mrWidgetPipelineMixin],
props: {
pipeline: {
type: Object,
@@ -172,7 +168,7 @@ export default {
</p>
</template>
<template v-else-if="!hasPipeline">
- <gl-loading-icon size="lg" />
+ <gl-loading-icon size="md" />
<p
class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0"
data-testid="monitoring-pipeline-message"
@@ -276,17 +272,15 @@ export default {
</div>
</div>
<div>
- <span class="gl-align-items-center gl-display-inline-flex mr-widget-pipeline-graph">
- <span class="gl-align-items-center gl-display-inline-flex gl-flex-wrap stage-cell">
- <linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" />
- <pipeline-mini-graph
- v-if="hasStages"
- stages-class="mr-widget-pipeline-stages"
- :stages="pipeline.details.stages"
- :is-merge-train="isMergeTrain"
- />
- </span>
- <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" />
+ <span class="gl-align-items-center gl-display-inline-flex">
+ <pipeline-mini-graph
+ v-if="pipeline.details.stages"
+ :downstream-pipelines="pipeline.triggered"
+ :is-merge-train="isMergeTrain"
+ :stages="pipeline.details.stages"
+ :upstream-pipeline="pipeline.triggered_by"
+ stages-class="mr-widget-pipeline-stages"
+ />
<pipeline-artifacts :pipeline-id="pipeline.id" :artifacts="artifacts" class="gl-ml-3" />
</span>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 5b8acb4ebf8..3239285e53e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -1,11 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import ciIcon from '~/vue_shared/components/ci_icon.vue';
+import { GlIcon } from '@gitlab/ui';
+import StatusIcon from './extensions/status_icon.vue';
export default {
components: {
- ciIcon,
- GlLoadingIcon,
+ StatusIcon,
+ GlIcon,
},
props: {
status: {
@@ -17,22 +17,20 @@ export default {
isLoading() {
return this.status === 'loading';
},
- statusObj() {
- return {
- group: this.status,
- icon: `status_${this.status}`,
- };
- },
},
};
</script>
<template>
- <div class="gl-display-flex gl-align-self-start">
- <div class="square s24 h-auto d-flex-center gl-mr-3">
- <div v-if="isLoading" class="mr-widget-icon gl-display-inline-flex">
- <gl-loading-icon size="md" class="mr-loading-icon gl-display-inline-flex" />
- </div>
- <ci-icon v-else :status="statusObj" :size="24" />
+ <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3">
+ <div class="gl-display-flex gl-m-auto">
+ <gl-icon v-if="status === 'merged'" name="merge" :size="16" class="gl-text-blue-500" />
+ <gl-icon
+ v-else-if="status === 'closed'"
+ name="merge-request-close"
+ :size="16"
+ class="gl-text-red-500"
+ />
+ <status-icon v-else :is-loading="isLoading" :icon-name="status" :level="1" class="gl-m-0!" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
index 4a5a03fb598..822c5a68093 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
@@ -1,13 +1,23 @@
<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
import StatusIcon from './mr_widget_status_icon.vue';
import Actions from './action_buttons.vue';
export default {
components: {
+ GlButton,
StatusIcon,
Actions,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
isLoading: {
type: Boolean,
required: false,
@@ -24,30 +34,67 @@ export default {
default: () => [],
},
},
+ i18n: {
+ expandDetailsTooltip: __('Expand merge details'),
+ collapseDetailsTooltip: __('Collapse merge details'),
+ },
+ computed: {
+ wrapperClasses() {
+ if (this.status === 'merged') return 'gl-bg-blue-50';
+ if (this.status === 'closed') return 'gl-bg-red-50';
+ return null;
+ },
+ },
};
</script>
<template>
- <div class="mr-widget-body media">
+ <div class="mr-widget-body media" :class="wrapperClasses" v-on="$listeners">
<div v-if="isLoading" class="gl-w-full mr-conflict-loader">
- <slot name="loading"></slot>
+ <slot name="loading">
+ <div class="gl-display-flex">
+ <status-icon status="loading" />
+ <div class="media-body">
+ <slot></slot>
+ </div>
+ </div>
+ </slot>
</div>
<template v-else>
<slot name="icon">
<status-icon :status="status" />
</slot>
- <div
- :class="{ 'gl-display-flex': actions.length, 'gl-md-display-flex': !actions.length }"
- class="media-body"
- >
- <slot></slot>
+ <div class="gl-display-flex gl-w-full">
+ <div
+ :class="{ 'gl-display-flex': actions.length, 'gl-md-display-flex': !actions.length }"
+ class="media-body"
+ >
+ <slot></slot>
+ <div
+ :class="{ 'gl-flex-direction-column-reverse': !actions.length }"
+ class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto"
+ >
+ <slot name="actions">
+ <actions v-if="actions.length" :tertiary-buttons="actions" />
+ </slot>
+ </div>
+ </div>
<div
- :class="{ 'gl-flex-direction-column-reverse': !actions.length }"
- class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto gl-mt-1"
+ class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1"
>
- <slot name="actions">
- <actions v-if="actions.length" :tertiary-buttons="actions" />
- </slot>
+ <gl-button
+ v-gl-tooltip
+ :title="
+ mr.mergeDetailsCollapsed
+ ? $options.i18n.expandDetailsTooltip
+ : $options.i18n.collapseDetailsTooltip
+ "
+ :icon="mr.mergeDetailsCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
+ category="tertiary"
+ size="small"
+ class="gl-vertical-align-top"
+ @click="() => mr.toggleMergeDetails()"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
index a45823823f0..e2a9caf5419 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
@@ -34,7 +34,7 @@ export default {
<template>
<div class="mr-widget-body media gl-flex-wrap">
- <status-icon status="warning" />
+ <status-icon status="failed" />
<p class="media-body gl-m-0! gl-font-weight-bold gl-text-black-normal!">
{{ failedText }}
</p>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
index f74826f95d3..79e878431ed 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -1,22 +1,24 @@
<script>
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetArchived',
components: {
- statusIcon,
+ StateContainer,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
},
};
</script>
+
<template>
- <div class="mr-widget-body media">
- <div class="space-children">
- <status-icon status="warning" show-disabled-button />
- </div>
- <div class="media-body">
- <span class="gl-ml-0! gl-text-body! bold">
- {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }}
- </span>
- </div>
- </div>
+ <state-container :mr="mr" status="failed">
+ <span class="gl-font-weight-bold">
+ {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }}
+ </span>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index 690acc9a6dc..3c6c2a44e70 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSkeletonLoader, GlIcon, GlSprintf } from '@gitlab/ui';
+import { GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge';
import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql';
import createFlash from '~/flash';
@@ -28,7 +28,6 @@ export default {
components: {
MrWidgetAuthor,
GlSkeletonLoader,
- GlIcon,
GlSprintf,
StateContainer,
},
@@ -151,7 +150,7 @@ export default {
};
</script>
<template>
- <state-container status="scheduled" :is-loading="loading" :actions="actions">
+ <state-container :mr="mr" status="scheduled" :is-loading="loading" :actions="actions">
<template #loading>
<gl-skeleton-loader :width="334" :height="30">
<rect x="0" y="3" width="24" height="24" rx="4" />
@@ -168,8 +167,5 @@ export default {
</gl-sprintf>
</h4>
</template>
- <template v-if="!loading" #icon>
- <gl-icon name="status_scheduled" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" />
- </template>
</state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
index b0cda85f361..39c56cbb93d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
@@ -58,8 +58,8 @@ export default {
};
</script>
<template>
- <state-container status="warning" :actions="actions">
- <span class="bold gl-ml-0!">
+ <state-container :mr="mr" status="failed" :actions="actions">
+ <span class="gl-font-weight-bold">
<template v-if="mergeError">{{ mergeError }}</template>
{{ s__('mrWidget|This merge request failed to be merged automatically') }}
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
index e2d87d8d536..922075516f3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -1,20 +1,23 @@
<script>
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetChecking',
components: {
- statusIcon,
+ StateContainer,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
},
};
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="loading" />
- <div class="media-body space-children">
- <span class="gl-ml-0! gl-text-body! bold">
- {{ s__('mrWidget|Checking if merge request can be merged…') }}
- </span>
- </div>
- </div>
+ <state-container :mr="mr" status="loading">
+ <span class="gl-font-weight-bold">
+ {{ s__('mrWidget|Checking if merge request can be merged…') }}
+ </span>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
index 61f7d26f51e..806f8f939a6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
@@ -1,16 +1,14 @@
<script>
import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetClosed',
components: {
MrWidgetAuthorTime,
- statusIcon,
+ StateContainer,
},
props: {
- /* TODO: This is providing all store and service down when it
- only needs metrics and targetBranch */
mr: {
type: Object,
required: true,
@@ -19,15 +17,12 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon status="warning" />
- <div class="media-body">
- <mr-widget-author-time
- :action-text="s__('mrWidget|Closed by')"
- :author="mr.metrics.closedBy"
- :date-title="mr.metrics.closedAt"
- :date-readable="mr.metrics.readableClosedAt"
- />
- </div>
- </div>
+ <state-container :mr="mr" status="closed">
+ <mr-widget-author-time
+ :action-text="s__('mrWidget|Closed by')"
+ :author="mr.metrics.closedBy"
+ :date-title="mr.metrics.closedAt"
+ :date-readable="mr.metrics.readableClosedAt"
+ />
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 8abd915b93e..d60d3cfc9ea 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
@@ -86,7 +86,7 @@ export default {
};
</script>
<template>
- <state-container status="warning" :is-loading="isLoading">
+ <state-container :mr="mr" status="failed" :is-loading="isLoading">
<template #loading>
<gl-skeleton-loader :width="334" :height="30">
<rect x="0" y="7" width="150" height="16" rx="4" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index 18103ac4a0e..8a7f15d8d1a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -1,16 +1,14 @@
<script>
-import { GlButton } from '@gitlab/ui';
import { stripHtml } from '~/lib/utils/text_utility';
import { sprintf, s__, n__ } from '~/locale';
import eventHub from '../../event_hub';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetFailedToMerge',
components: {
- GlButton,
- statusIcon,
+ StateContainer,
},
props: {
@@ -47,6 +45,16 @@ export default {
this.timer,
);
},
+ actions() {
+ return [
+ {
+ text: s__('mrWidget|Refresh now'),
+ onClick: () => this.refresh(),
+ testId: 'merge-request-failed-refresh-button',
+ dataQaSelector: 'merge_request_error_content',
+ },
+ ];
+ },
},
mounted() {
@@ -87,30 +95,18 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <template v-if="isRefreshing">
- <status-icon status="loading" />
- <span class="media-body bold js-refresh-label"> {{ s__('mrWidget|Refreshing now') }} </span>
- </template>
- <template v-else>
- <status-icon :show-disabled-button="true" status="warning" />
- <div class="media-body space-children">
- <span class="bold">
- <span v-if="mr.mergeError" class="has-error-message" data-testid="merge-error">
- {{ mergeError }}
- </span>
- <span v-else> {{ s__('mrWidget|Merge failed.') }} </span>
- <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span>
- </span>
- <gl-button
- size="small"
- data-testid="merge-request-failed-refresh-button"
- data-qa-selector="merge_request_error_content"
- @click="refresh"
- >
- {{ s__('mrWidget|Refresh now') }}
- </gl-button>
- </div>
- </template>
- </div>
+ <state-container v-if="isRefreshing" :mr="mr" status="loading">
+ <span class="gl-font-weight-bold">
+ {{ s__('mrWidget|Refreshing now') }}
+ </span>
+ </state-container>
+ <state-container v-else :mr="mr" status="failed" :actions="actions">
+ <span class="gl-font-weight-bold">
+ <span v-if="mr.mergeError" class="has-error-message" data-testid="merge-error">
+ {{ mergeError }}
+ </span>
+ <span v-else> {{ s__('mrWidget|Merge failed.') }} </span>
+ <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span>
+ </span>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index 4416123cd51..e9298b0c856 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective } from '@gitlab/ui';
import api from '~/api';
import createFlash from '~/flash';
import { s__, __ } from '~/locale';
@@ -16,7 +16,6 @@ export default {
},
components: {
MrWidgetAuthorTime,
- GlIcon,
StateContainer,
},
props: {
@@ -49,18 +48,6 @@ export default {
const { sourceBranchRemoved, isRemovingSourceBranch } = this.mr;
return !sourceBranchRemoved && (isRemovingSourceBranch || this.isMakingRequest);
},
- shouldShowMergedButtons() {
- const {
- canRevertInCurrentMR,
- canCherryPickInCurrentMR,
- revertInForkPath,
- cherryPickInForkPath,
- } = this.mr;
-
- return (
- canRevertInCurrentMR || canCherryPickInCurrentMR || revertInForkPath || cherryPickInForkPath
- );
- },
revertTitle() {
return s__('mrWidget|Revert this merge request in a new merge request');
},
@@ -163,10 +150,7 @@ export default {
};
</script>
<template>
- <state-container :actions="actions">
- <template #icon>
- <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" />
- </template>
+ <state-container :mr="mr" :actions="actions" status="merged">
<mr-widget-author-time
:action-text="s__('mrWidget|Merged by')"
:author="mr.metrics.mergedBy"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
index c7574a41bb8..51ac2576f75 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -4,7 +4,7 @@ import simplePoll from '~/lib/utils/simple_poll';
import MergeRequest from '~/merge_request';
import eventHub from '../../event_hub';
import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StatusIcon from '../mr_widget_status_icon.vue';
const { transitions } = STATE_MACHINE;
const { MERGE_FAILURE } = transitions;
@@ -12,7 +12,7 @@ const { MERGE_FAILURE } = transitions;
export default {
name: 'MRWidgetMerging',
components: {
- statusIcon,
+ StatusIcon,
},
props: {
mr: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index 659d12d1160..214d1b49732 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -9,7 +9,7 @@ import {
MR_WIDGET_MISSING_BRANCH_RESTORE,
MR_WIDGET_MISSING_BRANCH_MANUALCLI,
} from '../../i18n';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StatusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetMissingBranch',
@@ -19,7 +19,7 @@ export default {
components: {
GlIcon,
GlSprintf,
- statusIcon,
+ StatusIcon,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
@@ -71,10 +71,10 @@ export default {
</script>
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="warning" />
+ <status-icon :show-disabled-button="true" status="failed" />
<div class="media-body space-children">
- <span class="gl-ml-0! gl-text-body! bold js-branch-text" data-testid="widget-content">
+ <span class="gl-font-weight-bold js-branch-text" data-testid="widget-content">
<gl-sprintf :message="warning">
<template #code="{ content }">
<code>{{ content }}</code>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
index c203d2824fa..d837551a813 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
@@ -11,9 +11,9 @@ export default {
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="success" />
+ <status-icon status="success" />
<div class="media-body space-children">
- <span class="bold">
+ <span class="gl-font-weight-bold">
{{
s__(`mrWidget|Ready to be merged automatically.
Ask someone with write access to this repository to merge this request`)
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
index e99ee59b877..13920daca15 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
@@ -12,9 +12,9 @@ export default {
</script>
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="warning" />
+ <status-icon status="failed" />
<div class="media-body space-children">
- <span class="gl-ml-0! gl-text-body! bold">
+ <span class="gl-font-weight-bold">
{{
s__(
`mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`,
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 6c5fc916799..37c8d5d15f3 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
@@ -81,16 +81,19 @@ export default {
return 'loading';
}
if (!this.canPushToSourceBranch && !this.rebaseInProgress) {
- return 'warning';
+ return 'failed';
}
return 'success';
},
- showDisabledButton() {
- return ['failed', 'loading'].includes(this.status);
- },
fastForwardMergeText() {
return __('Merge blocked: the source branch must be rebased onto the target branch.');
},
+ showRebaseWithoutPipeline() {
+ return (
+ !this.mr.onlyAllowMergeIfPipelineSucceeds ||
+ (this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.allowMergeOnSkippedPipeline)
+ );
+ },
},
methods: {
rebase({ skipCi = false } = {}) {
@@ -149,7 +152,7 @@ export default {
};
</script>
<template>
- <state-container :status="status" :is-loading="isLoading">
+ <state-container :mr="mr" :status="status" :is-loading="isLoading">
<template #loading>
<gl-skeleton-loader :width="334" :height="30">
<rect x="0" y="3" width="24" height="24" rx="4" />
@@ -192,6 +195,7 @@ export default {
</template>
<template v-if="!isLoading" #actions>
<gl-button
+ v-if="showRebaseWithoutPipeline"
:loading="isMakingRequest"
variant="confirm"
size="small"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
index d507e5f232b..3cbd171a035 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
@@ -2,14 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StatusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'PipelineFailed',
components: {
GlLink,
GlSprintf,
- statusIcon,
+ StatusIcon,
},
computed: {
troubleshootingDocsPath() {
@@ -26,9 +26,9 @@ export default {
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="warning" />
+ <status-icon status="failed" />
<div class="media-body space-children">
- <span class="gl-ml-0! gl-text-body! bold">
+ <span class="gl-font-weight-bold">
<gl-sprintf :message="$options.i18n.failedMessage">
<template #link="{ content }">
<gl-link :href="troubleshootingDocsPath" target="_blank">
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 d2c85b14999..78430abcfe9 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
@@ -680,6 +680,7 @@ export default {
:is-fast-forward-enabled="!shouldShowMergeEdit"
:commits-count="commitsCount"
:target-branch="stateData.targetBranch"
+ :merge-commit-path="mr.mergeCommitPath"
/>
</li>
<li v-if="mr.state !== 'closed'" class="gl-line-height-normal">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
index d149f5208fc..27919f90cc3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -22,7 +22,7 @@ export default {
</script>
<template>
- <state-container status="warning">
+ <state-container :mr="mr" status="failed">
<span
class="gl-font-weight-bold gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!"
data-qa-selector="head_mismatch_content"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 035d62eaa59..8f2e4eb2131 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -24,7 +24,7 @@ export default {
</script>
<template>
- <state-container status="warning">
+ <state-container :mr="mr" status="failed">
<span
class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index cf7f83c014a..0458e9dfaf5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -163,7 +163,7 @@ export default {
</script>
<template>
- <state-container status="warning">
+ <state-container :mr="mr" status="failed">
<span class="gl-font-weight-bold gl-ml-0! gl-text-body! gl-flex-grow-1">
{{ __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") }}
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
index f1c1bde256f..2f52ac70833 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
@@ -15,7 +15,12 @@ export default {
</script>
<template>
- <section role="region" :aria-label="__('Merge request reports')" data-testid="mr-widget-app">
+ <section
+ v-if="widgets.length"
+ role="region"
+ :aria-label="__('Merge request reports')"
+ data-testid="mr-widget-app"
+ >
<component
:is="widget"
v-for="(widget, index) in widgets"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
index 9c8819327e6..c9fc2dde0bd 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
@@ -1,21 +1,32 @@
<script>
+import { GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { normalizeHeaders } from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
+import { sprintf, __ } from '~/locale';
import Poll from '~/lib/utils/poll';
import StatusIcon from '../extensions/status_icon.vue';
-import { EXTENSION_ICON_NAMES } from '../../constants';
+import ActionButtons from '../action_buttons.vue';
+import { EXTENSION_ICONS } from '../../constants';
+import ContentSection from './widget_content_section.vue';
const FETCH_TYPE_COLLAPSED = 'collapsed';
+const FETCH_TYPE_EXPANDED = 'expanded';
export default {
components: {
+ ActionButtons,
StatusIcon,
+ GlButton,
+ GlLoadingIcon,
+ ContentSection,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
/**
* @param {value.collapsed} Object
- * @param {value.extended} Object
+ * @param {value.expanded} Object
*/
value: {
type: Object,
@@ -35,7 +46,7 @@ export default {
type: Function,
required: true,
},
- fetchExtendedData: {
+ fetchExpandedData: {
type: Function,
required: false,
default: undefined,
@@ -61,7 +72,16 @@ export default {
type: String,
default: 'neutral',
required: false,
- validator: (value) => Object.keys(EXTENSION_ICON_NAMES).indexOf(value) > -1,
+ validator: (value) => Object.keys(EXTENSION_ICONS).indexOf(value) > -1,
+ },
+ isCollapsible: {
+ type: Boolean,
+ required: true,
+ },
+ actionButtons: {
+ type: Array,
+ required: false,
+ default: () => [],
},
widgetName: {
type: String,
@@ -70,10 +90,22 @@ export default {
},
data() {
return {
+ isExpandedForTheFirstTime: true,
+ isCollapsed: true,
isLoading: false,
- error: null,
+ isLoadingExpandedContent: false,
+ summaryError: null,
+ contentError: null,
};
},
+ computed: {
+ collapseButtonLabel() {
+ return sprintf(this.isCollapsed ? __('Show details') : __('Hide details'));
+ },
+ summaryStatusIcon() {
+ return this.summaryError ? this.$options.failedStatusIcon : this.statusIconName;
+ },
+ },
watch: {
isLoading(newValue) {
this.$emit('is-loading', newValue);
@@ -85,12 +117,36 @@ export default {
try {
await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED);
} catch {
- this.error = this.errorText;
+ this.summaryError = this.errorText;
}
this.isLoading = false;
},
methods: {
+ toggleCollapsed() {
+ this.isCollapsed = !this.isCollapsed;
+
+ if (this.isExpandedForTheFirstTime && typeof this.fetchExpandedData === 'function') {
+ this.isExpandedForTheFirstTime = false;
+ this.fetchExpandedContent();
+ }
+ },
+ async fetchExpandedContent() {
+ this.isLoadingExpandedContent = true;
+ this.contentError = null;
+
+ try {
+ await this.fetch(this.fetchExpandedData, FETCH_TYPE_EXPANDED);
+ } catch {
+ this.contentError = this.errorText;
+
+ // Reset these values so that we allow refetching
+ this.isExpandedForTheFirstTime = true;
+ this.isCollapsed = true;
+ }
+
+ this.isLoadingExpandedContent = false;
+ },
fetch(handler, dataType) {
const requests = this.multiPolling ? handler() : [handler];
@@ -125,6 +181,7 @@ export default {
});
},
},
+ failedStatusIcon: EXTENSION_ICONS.failed,
};
</script>
@@ -135,24 +192,58 @@ export default {
:level="1"
:name="widgetName"
:is-loading="isLoading"
- :icon-name="statusIconName"
+ :icon-name="summaryStatusIcon"
/>
<div
class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center"
data-testid="widget-extension-top-level"
>
<div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary">
- <slot name="summary">{{ isLoading ? loadingText : summary }}</slot>
+ <span v-if="summaryError">{{ summaryError }}</span>
+ <slot v-else name="summary">{{ isLoading ? loadingText : summary }}</slot>
+ </div>
+ <action-buttons
+ v-if="actionButtons.length > 0"
+ :widget="widgetName"
+ :tertiary-buttons="actionButtons"
+ />
+ <div
+ v-if="isCollapsible"
+ class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
+ >
+ <gl-button
+ v-gl-tooltip
+ :title="collapseButtonLabel"
+ :aria-expanded="`${!isCollapsed}`"
+ :aria-label="collapseButtonLabel"
+ :icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
+ category="tertiary"
+ data-testid="toggle-button"
+ size="small"
+ @click="toggleCollapsed"
+ />
</div>
- <!-- actions will go here -->
- <!-- toggle button will go here -->
</div>
</div>
<div
+ v-if="!isCollapsed || contentError"
class="mr-widget-grouped-section gl-relative"
data-testid="widget-extension-collapsed-section"
>
- <slot name="content">{{ content }}</slot>
+ <div v-if="isLoadingExpandedContent" class="report-block-container gl-text-center">
+ <gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
+ </div>
+ <content-section
+ v-else-if="contentError"
+ class="report-block-container"
+ :status-icon-name="$options.failedStatusIcon"
+ :widget-name="widgetName"
+ >
+ {{ contentError }}
+ </content-section>
+ <slot v-else name="content">
+ {{ content }}
+ </slot>
</div>
</section>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue
new file mode 100644
index 00000000000..61e3744b5dc
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue
@@ -0,0 +1,35 @@
+<script>
+import { EXTENSION_ICONS } from '../../constants';
+import StatusIcon from '../extensions/status_icon.vue';
+
+export default {
+ components: {
+ StatusIcon,
+ },
+ props: {
+ statusIconName: {
+ type: String,
+ default: '',
+ required: false,
+ validator: (value) => value === '' || Object.keys(EXTENSION_ICONS).includes(value),
+ },
+ widgetName: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-px-7">
+ <div class="gl-pl-4 gl-display-flex">
+ <status-icon
+ v-if="statusIconName"
+ :level="2"
+ :name="widgetName"
+ :icon-name="statusIconName"
+ />
+ <slot name="default"></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index c148a35209f..be4e34ffff0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -140,6 +140,7 @@ export const EXTENSION_ICON_NAMES = {
neutral: 'status-neutral',
error: 'status-alert',
notice: 'status-alert',
+ scheduled: 'status-scheduled',
severityCritical: 'severity-critical',
severityHigh: 'severity-high',
severityMedium: 'severity-medium',
@@ -155,6 +156,7 @@ export const EXTENSION_ICON_CLASS = {
neutral: 'gl-text-gray-400',
error: 'gl-text-red-500',
notice: 'gl-text-gray-500',
+ scheduled: 'gl-text-blue-500',
severityCritical: 'gl-text-red-800',
severityHigh: 'gl-text-red-600',
severityMedium: 'gl-text-orange-400',
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
index c74445a5b80..97b9b59e2c3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
@@ -32,7 +32,7 @@ export default {
});
});
- return fileNames.join(' ');
+ return fileNames.join(' ').trim();
},
summary(data) {
if (data.parsingInProgress) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
deleted file mode 100644
index 7b77d7475bc..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export default {
- computed: {
- triggered() {
- return [];
- },
- triggeredBy() {
- return [];
- },
- },
-};
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 1e25143e15c..c8a2a8d119b 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
@@ -59,28 +59,28 @@ export default {
Loading,
ExtensionsContainer,
WidgetContainer,
- 'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
+ MrWidgetSuggestPipeline: WidgetSuggestPipeline,
MrWidgetPipelineContainer,
MrWidgetAlertMessage,
- 'mr-widget-merged': MergedState,
- 'mr-widget-closed': ClosedState,
- 'mr-widget-merging': MergingState,
- 'mr-widget-failed-to-merge': FailedToMerge,
- 'mr-widget-wip': WorkInProgressState,
- 'mr-widget-archived': ArchivedState,
- 'mr-widget-conflicts': ConflictsState,
- 'mr-widget-nothing-to-merge': NothingToMergeState,
- 'mr-widget-not-allowed': NotAllowedState,
- 'mr-widget-missing-branch': MissingBranchState,
- 'mr-widget-ready-to-merge': () => import('./components/states/new_ready_to_merge.vue'),
- 'sha-mismatch': ShaMismatch,
- 'mr-widget-checking': CheckingState,
- 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
- 'mr-widget-pipeline-blocked': PipelineBlockedState,
- 'mr-widget-pipeline-failed': PipelineFailedState,
+ MrWidgetMerged: MergedState,
+ MrWidgetClosed: ClosedState,
+ MrWidgetMerging: MergingState,
+ MrWidgetFailedToMerge: FailedToMerge,
+ MrWidgetWip: WorkInProgressState,
+ MrWidgetArchived: ArchivedState,
+ MrWidgetConflicts: ConflictsState,
+ MrWidgetNothingToMerge: NothingToMergeState,
+ MrWidgetNotAllowed: NotAllowedState,
+ MrWidgetMissingBranch: MissingBranchState,
+ MrWidgetReadyToMerge: () => import('./components/states/new_ready_to_merge.vue'),
+ ShaMismatch,
+ MrWidgetChecking: CheckingState,
+ MrWidgetUnresolvedDiscussions: UnresolvedDiscussionsState,
+ MrWidgetPipelineBlocked: PipelineBlockedState,
+ MrWidgetPipelineFailed: PipelineFailedState,
MrWidgetAutoMergeEnabled,
- 'mr-widget-auto-merge-failed': AutoMergeFailed,
- 'mr-widget-rebase': RebaseState,
+ MrWidgetAutoMergeFailed: AutoMergeFailed,
+ MrWidgetRebase: RebaseState,
SourceBranchRemovalStatus,
GroupedCodequalityReportsApp: () =>
import('../reports/codequality_report/grouped_codequality_reports_app.vue'),
@@ -230,6 +230,11 @@ export default {
shouldShowCodeQualityExtension() {
return window.gon?.features?.refactorCodeQualityExtension;
},
+ shouldShowMergeDetails() {
+ if (this.mr.state === 'readyToMerge') return true;
+
+ return !this.mr.mergeDetailsCollapsed;
+ },
},
watch: {
'mr.machineValue': {
@@ -318,6 +323,12 @@ export default {
this.initPolling();
this.bindEventHubListeners();
eventHub.$on('mr.discussion.updated', this.checkStatus);
+
+ window.addEventListener('resize', () => {
+ if (window.innerWidth >= 768) {
+ this.mr.toggleMergeDetails(false);
+ }
+ });
},
getServiceEndpoints(store) {
return {
@@ -428,6 +439,7 @@ export default {
.then((res) => {
if (res.data) {
const el = document.createElement('div');
+ // eslint-disable-next-line no-unsanitized/property
el.innerHTML = res.data;
document.body.appendChild(el);
document.dispatchEvent(new CustomEvent('merged:UpdateActions'));
@@ -620,7 +632,12 @@ export default {
<div class="mr-widget-section" data-qa-selector="mr_widget_content">
<component :is="componentName" :mr="mr" :service="service" />
- <ready-to-merge v-if="mr.commitsCount" :mr="mr" :service="service" />
+ <ready-to-merge
+ v-if="mr.commitsCount"
+ v-show="shouldShowMergeDetails"
+ :mr="mr"
+ :service="service"
+ />
</div>
</div>
<mr-widget-pipeline-container
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
index 981c667f27a..eac72ffb2f2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
@@ -3,6 +3,7 @@ query getState($projectPath: ID!, $iid: String!) {
id
archived
onlyAllowMergeIfPipelineSucceeds
+ allowMergeOnSkippedPipeline
mergeRequest(iid: $iid) {
id
autoMergeEnabled
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 146cf7e11a7..e6ff586892f 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
@@ -28,6 +28,7 @@ export default class MergeRequestStore {
this.stateMachine = machine(STATE_MACHINE.definition);
this.machineValue = this.stateMachine.value;
+ this.mergeDetailsCollapsed = window.innerWidth < 768;
this.setPaths(data);
@@ -168,6 +169,7 @@ export default class MergeRequestStore {
this.mergeError = data.merge_error;
this.mergeStatus = data.merge_status;
this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
+ this.allowMergeOnSkippedPipeline = data.allow_merge_on_skipped_pipeline || false;
this.projectArchived = data.project_archived;
this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.shouldBeRebased = Boolean(data.should_be_rebased);
@@ -195,6 +197,7 @@ export default class MergeRequestStore {
this.projectArchived = project.archived;
this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds;
+ this.allowMergeOnSkippedPipeline = project.allowMergeOnSkippedPipeline;
this.autoMergeEnabled = mergeRequest.autoMergeEnabled;
this.canBeMerged = mergeRequest.mergeStatus === 'can_be_merged';
@@ -403,4 +406,8 @@ export default class MergeRequestStore {
this.transitionStateMachine(transitionOptions);
}
+
+ toggleMergeDetails(val = !this.mergeDetailsCollapsed) {
+ this.mergeDetailsCollapsed = val;
+ }
}
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
index 6db18afe51c..c6c22f9c61f 100644
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ b/app/assets/javascripts/vue_shared/components/actions_button.vue
@@ -77,7 +77,7 @@ export default {
<template v-for="(action, index) in actions">
<gl-dropdown-item
:key="action.key"
- :is-check-item="true"
+ is-check-item
:is-checked="action.key === selectedAction.key"
:secondary-text="action.secondaryText"
:data-qa-selector="`${action.key}_menu_item`"
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index 5de71c35be9..84bd6bca601 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
+import { visitUrl } from '~/lib/utils/url_utility';
import CiIcon from './ci_icon.vue';
/**
* Renders CI Badge link with CI icon and status text based on
@@ -57,13 +58,28 @@ export default {
},
cssClass() {
const className = this.status.group;
- return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge';
+ return className ? `ci-status ci-${className}` : 'ci-status';
+ },
+ },
+ methods: {
+ navigateToPipeline() {
+ visitUrl(this.detailsPath);
+
+ // event used for tracking
+ this.$emit('ciStatusBadgeClick');
},
},
};
</script>
<template>
- <a v-gl-tooltip :href="detailsPath" :class="cssClass" :title="title">
+ <a
+ v-gl-tooltip
+ :class="cssClass"
+ class="gl-cursor-pointer"
+ :title="title"
+ data-qa-selector="status_badge_link"
+ @click="navigateToPipeline"
+ >
<ci-icon :status="status" :css-classes="iconClasses" />
<template v-if="showText">
diff --git a/app/assets/javascripts/vue_shared/components/code_block.stories.js b/app/assets/javascripts/vue_shared/components/code_block.stories.js
new file mode 100644
index 00000000000..e02a346c1de
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/code_block.stories.js
@@ -0,0 +1,18 @@
+import CodeBlock from './code_block.vue';
+
+export default {
+ component: CodeBlock,
+ title: 'vue_shared/code_block',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { CodeBlock },
+ props: Object.keys(argTypes),
+ template: '<code-block v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ code: `git commit -a "Message"\ngit push`,
+};
diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue
index 9856f35c7f6..4a69845d3a4 100644
--- a/app/assets/javascripts/vue_shared/components/code_block.vue
+++ b/app/assets/javascripts/vue_shared/components/code_block.vue
@@ -4,7 +4,8 @@ export default {
props: {
code: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
maxHeight: {
type: String,
@@ -32,5 +33,5 @@ export default {
class="code-block rounded code"
:class="$options.userColorScheme"
:style="styleObject"
- ><code class="d-block">{{ code }}</code></pre>
+ ><slot><code class="d-block">{{ code }}</code></slot></pre>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js b/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js
new file mode 100644
index 00000000000..bf81a811d16
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js
@@ -0,0 +1,18 @@
+import CodeBlockHighlighted from './code_block_highlighted.vue';
+
+export default {
+ component: CodeBlockHighlighted,
+ title: 'vue_shared/code_block_highlighted',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { CodeBlockHighlighted },
+ props: Object.keys(argTypes),
+ template: '<code-block-highlighted v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ code: `const foo = 1;\nconsole.log(foo + ' yay')`,
+ language: 'javascript',
+};
diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue
new file mode 100644
index 00000000000..65b08b608e8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlSafeHtmlDirective } from '@gitlab/ui';
+
+import languageLoader from '~/content_editor/services/highlight_js_language_loader';
+import CodeBlock from './code_block.vue';
+
+export default {
+ name: 'CodeBlockHighlighted',
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ components: {
+ CodeBlock,
+ },
+ props: {
+ code: {
+ type: String,
+ required: true,
+ },
+ language: {
+ type: String,
+ required: true,
+ },
+ maxHeight: {
+ type: String,
+ required: false,
+ default: 'initial',
+ },
+ },
+ data() {
+ return {
+ hljs: null,
+ languageLoaded: false,
+ };
+ },
+ computed: {
+ highlighted() {
+ if (this.hljs && this.languageLoaded) {
+ return this.hljs.highlight(this.code, { language: this.language }).value;
+ }
+
+ return this.code;
+ },
+ },
+ async mounted() {
+ this.hljs = await this.loadHighlightJS();
+ if (this.language) {
+ await this.loadLanguage();
+ }
+ },
+ methods: {
+ async loadLanguage() {
+ try {
+ const { default: languageDefinition } = await languageLoader[this.language]();
+
+ this.hljs.registerLanguage(this.language, languageDefinition);
+ this.languageLoaded = true;
+ } catch (e) {
+ this.$emit('error', e);
+ }
+ },
+ loadHighlightJS() {
+ return import('highlight.js/lib/core');
+ },
+ },
+};
+</script>
+<template>
+ <code-block :max-height="maxHeight" class="highlight">
+ <span v-safe-html="highlighted"></span>
+ </code-block>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
index 91906388049..22f3c35b9c3 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
@@ -42,8 +42,8 @@ export default {
v-for="color in colors"
:key="color.color"
:is-checked="isColorSelected(color)"
- :is-check-centered="true"
- :is-check-item="true"
+ is-check-centered
+ is-check-item
@click.native.capture.stop="handleColorClick(color)"
>
<color-item :color="color.color" :title="color.title" />
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
index 8481280f25f..7ecc309db52 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
@@ -3,7 +3,7 @@ import ConfirmDanger from './confirm_danger.vue';
export default {
component: ConfirmDanger,
- title: 'vue_shared/components/modals/confirm_danger_modal',
+ title: 'vue_shared/modals/confirm_danger_modal',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
index aec67a18a05..38b1a587b34 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
@@ -1,4 +1,4 @@
-import dateformat from 'dateformat';
+import dateformat from '~/lib/dateformat';
import { __ } from '~/locale';
/**
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
index eeed5e9dc3a..8256d953466 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
@@ -5,7 +5,7 @@ import DropdownWidget from './dropdown_widget.vue';
export default {
component: DropdownWidget,
- title: 'vue_shared/components/dropdown/dropdown_widget/dropdown_widget',
+ title: 'vue_shared/dropdown/dropdown_widget/dropdown_widget',
};
const Template = (args, { argTypes }) => ({
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 840911dc99c..faa50a50c69 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
@@ -149,8 +149,8 @@ export default {
v-for="option in presetOptions"
:key="option.id"
:is-checked="isSelected(option)"
- :is-check-centered="true"
- :is-check-item="true"
+ is-check-centered
+ is-check-item
@click.native.capture.stop="selectOption(option)"
>
<slot name="preset-item" :item="option">
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 5d7f4ae2a01..ffe09634a3b 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -46,7 +46,7 @@ export const SortDirection = {
export const FILTERED_SEARCH_LABELS = 'labels';
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
-export const TOKEN_TITLE_ASSIGNEE = __('Assignee');
+export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee');
export const TOKEN_TITLE_AUTHOR = __('Author');
export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
export const TOKEN_TITLE_CONTACT = s__('Crm|Contact');
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 33d507dad57..e311df6e66f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -369,7 +369,7 @@ export default {
<gl-dropdown-item
v-for="sortBy in sortOptions"
:key="sortBy.id"
- :is-check-item="true"
+ is-check-item
:is-checked="sortBy.id === selectedSortOption.id"
@click="handleSortOptionClick(sortBy)"
>{{ sortBy.title }}</gl-dropdown-item
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
index cdd7a074f34..377f1e7c136 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
@@ -2,7 +2,7 @@ import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue';
export default {
component: InputCopyToggleVisibility,
- title: 'vue_shared/components/form/input_copy_toggle_visibility',
+ title: 'vue_shared/form/input_copy_toggle_visibility',
};
const defaultProps = {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 1d1b65aa1af..458dfe0ed23 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -182,7 +182,7 @@ export default {
<div class="md-header">
<gl-tabs content-class="gl-display-none">
<gl-tab
- title-link-class="gl-pt-3 gl-px-3 js-md-write-button"
+ title-link-class="gl-py-4 gl-px-3 js-md-write-button"
:title="$options.i18n.writeTabTitle"
:active="!previewMarkdown"
data-testid="write-tab"
@@ -190,7 +190,7 @@ export default {
/>
<gl-tab
v-if="enablePreview"
- title-link-class="gl-pt-3 gl-px-3 js-md-preview-button"
+ title-link-class="gl-py-4 gl-px-3 js-md-preview-button"
:title="$options.i18n.previewTabTitle"
:active="previewMarkdown"
data-testid="preview-tab"
@@ -201,7 +201,7 @@ export default {
<div
data-testid="md-header-toolbar"
:class="{ 'gl-display-none!': previewMarkdown }"
- class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center"
+ class="md-header-toolbar gl-ml-auto gl-py-2 gl-justify-content-center"
>
<template v-if="canSuggest">
<toolbar-button
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index de3eda6b04f..9b81444fc04 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -163,6 +163,7 @@ export default {
// resets the container HTML (replaces it with the updated noteHTML)
// calls `renderSuggestions` once the updated noteHTML is added to the DOM
+ // eslint-disable-next-line no-unsanitized/property
this.$refs.container.innerHTML = this.noteHtml;
this.isRendered = false;
this.renderSuggestions();
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index aa325862f06..b5640e12541 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -72,7 +72,7 @@ export default {
</gl-sprintf>
</template>
</div>
- <span v-if="canAttachFile" class="uploading-container">
+ <span v-if="canAttachFile" class="uploading-container gl-line-height-32">
<span class="uploading-progress-container hide">
<gl-icon name="paperclip" />
<span class="attaching-file-message"></span>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 3593ea16968..7e99f1b01b2 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -29,7 +29,7 @@ import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_
import '~/behaviors/markdown/render_gfm';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
-import noteHeader from '~/notes/components/note_header.vue';
+import NoteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { spriteIcon } from '~/lib/utils/common_utils';
import TimelineEntryItem from './timeline_entry_item.vue';
@@ -43,7 +43,7 @@ export default {
name: 'SystemNote',
components: {
GlIcon,
- noteHeader,
+ NoteHeader,
TimelineEntryItem,
GlButton,
GlSkeletonLoader,
diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
index e31446f4bb8..f16afc77164 100644
--- a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
+++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
@@ -3,7 +3,7 @@ import PaginationBar from './pagination_bar.vue';
export default {
component: PaginationBar,
- title: 'vue_shared/components/pagination_bar/pagination_bar',
+ title: 'vue_shared/pagination_bar/pagination_bar',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.stories.js b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
index 110c6c73bad..bfb30c74cb8 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
@@ -2,7 +2,7 @@ import ProjectAvatar from './project_avatar.vue';
export default {
component: ProjectAvatar,
- title: 'vue_shared/components/project_avatar',
+ title: 'vue_shared/project_avatar',
};
const Template = (args, { argTypes }) => ({
@@ -13,8 +13,7 @@ const Template = (args, { argTypes }) => ({
export const Default = Template.bind({});
Default.args = {
- projectAvatarUrl:
- 'https://gitlab.com/uploads/-/system/project/avatar/278964/logo-extra-whitespace.png?width=64',
+ projectAvatarUrl: 'https://gitlab.com/uploads/-/system/project/avatar/278964/project_avatar.png',
projectName: 'GitLab',
};
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
index 9700117a3da..4021e23a3f6 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
@@ -2,7 +2,7 @@ import ProjectListItem from './project_list_item.vue';
export default {
component: ProjectListItem,
- title: 'vue_shared/components/project_selector/project_list_item',
+ title: 'vue_shared/project_selector/project_list_item',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
index 43a8e241d77..32d7cdad568 100644
--- a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
@@ -49,7 +49,7 @@ export default {
v-for="option in parsedOptions"
:key="option.value"
:is-checked="option.selected"
- :is-check-item="true"
+ is-check-item
@click="setSelected(option.value)"
>
{{ option.label }}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index bfaf3b92c34..c5d3704ead9 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -253,7 +253,7 @@ export default {
<gl-dropdown-item
v-for="architecture in architectures"
:key="architecture.name"
- :is-check-item="true"
+ is-check-item
:is-checked="selectedArchitecture === architecture.name"
data-testid="architecture-dropdown-item"
@click="selectArchitecture(architecture.name)"
diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
index 5242743ad30..53e4a08e486 100644
--- a/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
+++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
@@ -2,7 +2,7 @@ import SettingsBlock from './settings_block.vue';
export default {
component: SettingsBlock,
- title: 'vue_shared/components/settings/settings_block',
+ title: 'vue_shared/settings/settings_block',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
index dfa2ca2d20c..0f5560ff628 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
@@ -180,7 +180,7 @@ export default {
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
- :is-check-item="true"
+ is-check-item
:is-checked="isSelectedProject(project)"
@click.stop.prevent="handleProjectSelect(project)"
>{{ project.name_with_namespace }}</gl-dropdown-item
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
index f595e635f2c..8d3d4d5f86a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
@@ -154,8 +154,8 @@ export default {
v-for="(label, index) in visibleLabels"
:key="label.id"
:is-checked="isLabelSelected(label)"
- :is-check-centered="true"
- :is-check-item="true"
+ is-check-centered
+ is-check-item
:active="shouldHighlightFirstItem && index === 0"
active-class="is-focused"
data-testid="labels-list"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
index aaddab43e2a..154a8e866d0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
@@ -80,6 +80,7 @@ export default {
v-if="!showDropdownContentsCreateView"
ref="searchInput"
:value="searchKey"
+ :placeholder="__('Search labels')"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
data-testid="dropdown-input-field"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
index 294e5bd9f90..8a2bab4cb9a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
@@ -4,7 +4,7 @@ import TodoButton from './todo_button.vue';
export default {
component: TodoButton,
- title: 'vue_shared/components/sidebar/todo_toggle/todo_button',
+ title: 'vue_shared/sidebar/todo_toggle/todo_button',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index cc930d67fa4..30f57f506a6 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -81,6 +81,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
protobuf: 'protobuf',
puppet: 'puppet',
python: 'python',
+ python3: 'python',
q: 'q',
qml: 'qml',
r: 'r',
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js
index 5be92af5b55..8b52df83fdf 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js
@@ -3,6 +3,8 @@ import { HLJS_COMMENT_SELECTOR } from '../constants';
const createWrapper = (content) => {
const span = document.createElement('span');
span.className = HLJS_COMMENT_SELECTOR;
+
+ // eslint-disable-next-line no-unsanitized/property
span.innerHTML = content;
return span.outerHTML;
};
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 f471db24889..9c6c12eac7d 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
@@ -42,7 +42,7 @@ export default {
return {
languageDefinition: null,
content: this.blob.rawTextBlob,
- language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language],
+ language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()],
hljs: null,
firstChunk: null,
chunks: {},
@@ -62,7 +62,7 @@ export default {
const supportedLanguages = Object.keys(languageLoader);
return (
!supportedLanguages.includes(this.language) &&
- !supportedLanguages.includes(this.blob.language)
+ !supportedLanguages.includes(this.blob.language?.toLowerCase())
);
},
},
diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue
index 994fa68fb1a..c0aef42b0f2 100644
--- a/app/assets/javascripts/vue_shared/components/split_button.vue
+++ b/app/assets/javascripts/vue_shared/components/split_button.vue
@@ -68,7 +68,7 @@ export default {
<template v-for="(item, itemIndex) in actionItems">
<gl-dropdown-item
:key="item.eventName"
- :is-check-item="true"
+ is-check-item
:is-checked="selectedItem === item"
@click="changeSelectedItem(item)"
>
diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
index 42334d80eec..ce65266cbc9 100644
--- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
@@ -72,7 +72,7 @@ export default {
v-for="timezone in filteredResults"
:key="timezone.formattedTimezone"
:is-checked="isSelected(timezone)"
- :is-check-item="true"
+ is-check-item
@click="selectTimezone(timezone)"
>
{{ timezone.formattedTimezone }}
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
index f27901a30a9..e621442e601 100644
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
@@ -5,7 +5,7 @@ const defaultWidth = '250px';
export default {
component: TooltipOnTruncate,
- title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue',
+ title: 'vue_shared/tooltip_on_truncate/tooltip_on_truncate.vue',
};
const createStory = ({ ...options }) => {
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index 424cab20c7e..a001b6bdf24 100644
--- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -149,7 +149,7 @@ export default {
>
<slot>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
type="button"
@click="openFileUpload"
>
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
index cd610314292..6bd66981860 100644
--- 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
@@ -90,9 +90,8 @@ export default {
</script>
<template>
- <span>
+ <span ref="userAvatar">
<gl-avatar
- ref="userAvatar"
:class="{
lazy: lazy,
[cssClasses]: true,
@@ -108,7 +107,7 @@ export default {
tooltipText ||
$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
"
- :target="() => $refs.userAvatar.$el"
+ :target="() => $refs.userAvatar"
:placement="tooltipPlacement"
boundary="window"
>
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
index d2030c14029..1f0f4cde234 100644
--- a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
@@ -5,7 +5,7 @@ import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue';
export default {
component: UserDeletionObstaclesList,
- title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list',
+ title: 'vue_shared/user_deletion_obstacles/user_deletion_obstacles_list',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/constants.js b/app/assets/javascripts/vue_shared/components/user_popover/constants.js
index 1d49aefd297..bcbe72b4b4f 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/constants.js
+++ b/app/assets/javascripts/vue_shared/components/user_popover/constants.js
@@ -1 +1,14 @@
+import { __ } from '~/locale';
+
export const USER_POPOVER_DELAY = 200;
+export const I18N_ERROR_FOLLOW = __(
+ 'An error occurred while trying to follow this user, please try again.',
+);
+export const I18N_ERROR_UNFOLLOW = __(
+ 'An error occurred while trying to unfollow this user, please try again.',
+);
+export const I18N_USER_BLOCKED = __('User is blocked');
+export const I18N_USER_BUSY = __('Busy');
+export const I18N_USER_LEARN = __('Learn more about %{name}');
+export const I18N_USER_FOLLOW = __('Follow');
+export const I18N_USER_UNFOLLOW = __('Unfollow');
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 2b9804796ae..4b39a8e45bb 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
@@ -9,23 +9,31 @@ import {
GlButton,
GlAvatarLabeled,
} from '@gitlab/ui';
-import { __ } from '~/locale';
import { glEmojiTag } from '~/emoji';
import createFlash from '~/flash';
import { followUser, unfollowUser } from '~/rest_api';
import { isUserBusy } from '~/set_status_modal/utils';
import Tracking from '~/tracking';
-import { USER_POPOVER_DELAY } from './constants';
+import {
+ I18N_ERROR_FOLLOW,
+ I18N_ERROR_UNFOLLOW,
+ I18N_USER_BLOCKED,
+ I18N_USER_BUSY,
+ I18N_USER_LEARN,
+ I18N_USER_FOLLOW,
+ I18N_USER_UNFOLLOW,
+ USER_POPOVER_DELAY,
+} from './constants';
const MAX_SKELETON_LINES = 4;
export default {
name: 'UserPopover',
maxSkeletonLines: MAX_SKELETON_LINES,
+ I18N_USER_BLOCKED,
+ I18N_USER_BUSY,
+ I18N_USER_LEARN,
USER_POPOVER_DELAY,
- i18n: {
- busy: __('Busy'),
- },
components: {
GlIcon,
GlLink,
@@ -94,7 +102,7 @@ export default {
toggleFollowButtonText() {
if (this.toggleFollowLoading) return null;
- return this.user?.isFollowed ? __('Unfollow') : __('Follow');
+ return this.user?.isFollowed ? I18N_USER_UNFOLLOW : I18N_USER_FOLLOW;
},
toggleFollowButtonVariant() {
return this.user?.isFollowed ? 'default' : 'confirm';
@@ -102,6 +110,9 @@ export default {
hasPronouns() {
return Boolean(this.user?.pronouns?.trim());
},
+ isBlocked() {
+ return this.user?.state === 'blocked';
+ },
isBusy() {
return isUserBusy(this.availabilityStatus);
},
@@ -129,7 +140,7 @@ export default {
this.$emit('follow');
} catch (error) {
createFlash({
- message: __('An error occurred while trying to follow this user, please try again.'),
+ message: I18N_ERROR_FOLLOW,
error,
captureError: true,
});
@@ -149,7 +160,7 @@ export default {
this.$emit('unfollow');
} catch (error) {
createFlash({
- message: __('An error occurred while trying to unfollow this user, please try again.'),
+ message: I18N_ERROR_UNFOLLOW,
error,
captureError: true,
});
@@ -189,16 +200,21 @@ export default {
:label="user.name"
:sub-label="username"
>
- <gl-button
- v-if="shouldRenderToggleFollowButton"
- class="gl-mt-3 gl-align-self-start"
- :variant="toggleFollowButtonVariant"
- :loading="toggleFollowLoading"
- size="small"
- data-testid="toggle-follow-button"
- @click="toggleFollow"
- >{{ toggleFollowButtonText }}</gl-button
- >
+ <template v-if="isBlocked">
+ <span class="gl-mt-4 gl-font-style-italic">{{ $options.I18N_USER_BLOCKED }}</span>
+ </template>
+ <template v-else>
+ <gl-button
+ v-if="shouldRenderToggleFollowButton"
+ class="gl-mt-3 gl-align-self-start"
+ :variant="toggleFollowButtonVariant"
+ :loading="toggleFollowLoading"
+ size="small"
+ data-testid="toggle-follow-button"
+ @click="toggleFollow"
+ >{{ toggleFollowButtonText }}</gl-button
+ >
+ </template>
<template #meta>
<span
@@ -208,7 +224,7 @@ export default {
>({{ user.pronouns }})</span
>
<span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1"
- >({{ $options.i18n.busy }})</span
+ >({{ $options.I18N_USER_BUSY }})</span
>
</template>
</gl-avatar-labeled>
@@ -223,39 +239,41 @@ export default {
/>
</template>
<template v-else>
- <div class="gl-text-gray-500">
- <div v-if="user.bio" class="gl-display-flex gl-mb-2">
- <gl-icon name="profile" class="gl-flex-shrink-0" />
- <span ref="bio" class="gl-ml-2">{{ user.bio }}</span>
+ <template v-if="!isBlocked">
+ <div class="gl-text-gray-500">
+ <div v-if="user.bio" class="gl-display-flex gl-mb-2">
+ <gl-icon name="profile" class="gl-flex-shrink-0" />
+ <span ref="bio" class="gl-ml-2">{{ user.bio }}</span>
+ </div>
+ <div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
+ <gl-icon name="work" class="gl-flex-shrink-0" />
+ <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
+ </div>
+ <div v-if="user.location" class="gl-display-flex gl-mb-2">
+ <gl-icon name="location" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.location }}</span>
+ </div>
+ <div
+ v-if="user.localTime && !user.bot"
+ class="gl-display-flex gl-mb-2"
+ data-testid="user-popover-local-time"
+ >
+ <gl-icon name="clock" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.localTime }}</span>
+ </div>
</div>
- <div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
- <gl-icon name="work" class="gl-flex-shrink-0" />
- <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
+ <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.location" class="gl-display-flex gl-mb-2">
- <gl-icon name="location" class="gl-flex-shrink-0" />
- <span class="gl-ml-2">{{ user.location }}</span>
+ <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="$options.I18N_USER_LEARN">
+ <template #name>{{ user.name }}</template>
+ </gl-sprintf>
+ </gl-link>
</div>
- <div
- v-if="user.localTime && !user.bot"
- class="gl-display-flex gl-mb-2"
- data-testid="user-popover-local-time"
- >
- <gl-icon name="clock" class="gl-flex-shrink-0" />
- <span class="gl-ml-2">{{ user.localTime }}</span>
- </div>
- </div>
- <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 && 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}')">
- <template #username>{{ user.name }}</template>
- </gl-sprintf>
- </gl-link>
- </div>
+ </template>
</template>
</div>
</gl-popover>
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 43a590c2367..3180bd0d283 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
@@ -320,7 +320,7 @@ export default {
<gl-dropdown-item
v-if="isSearchEmpty"
:is-checked="selectedIsEmpty"
- :is-check-centered="true"
+ is-check-centered
data-testid="unassign"
@click.native.capture.stop="$emit('input', [])"
>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
index 38083327593..7e735f358eb 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
@@ -232,7 +232,11 @@ export default {
</span>
</div>
<div class="issuable-info">
- <work-item-type-icon v-if="showWorkItemTypeIcon" :work-item-type="issuable.type" />
+ <work-item-type-icon
+ v-if="showWorkItemTypeIcon"
+ :work-item-type="issuable.type"
+ show-tooltip-on-hover
+ />
<slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference">
{{ reference }}
diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue
index d2fc2c66924..e42720bf1db 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue
@@ -10,6 +10,7 @@ export default {
mounted() {
const legacyEntry = document.querySelector(this.selector);
if (legacyEntry.tagName === 'TEMPLATE') {
+ // eslint-disable-next-line no-unsanitized/property
this.$el.innerHTML = legacyEntry.innerHTML;
} else {
this.source = legacyEntry.parentNode;
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
index 2e80db30e9a..6a83669d206 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
@@ -14,6 +14,7 @@ query securityReportDownloadPaths(
id
name
artifacts {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
nodes {
downloadPath
fileType
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
index e4f0c392b91..1f1e56a5876 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
@@ -4,6 +4,7 @@ query getPipelineCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityRe
project(fullPath: $projectPath) {
id
pipeline(iid: $iid) {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
...JobArtifacts
}
}
diff --git a/app/assets/javascripts/webpack_non_compiled_placeholder.js b/app/assets/javascripts/webpack_non_compiled_placeholder.js
index af671e72129..c1baa7b8dd3 100644
--- a/app/assets/javascripts/webpack_non_compiled_placeholder.js
+++ b/app/assets/javascripts/webpack_non_compiled_placeholder.js
@@ -20,6 +20,7 @@ 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.';
+// eslint-disable-next-line no-unsanitized/property
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">
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 551ebbadb21..b2c8b7ae1db 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -39,14 +39,14 @@ export default {
:class="{ 'gl-cursor-text': disabled }"
aria-labelledby="item-title"
>
- <div
+ <span
id="item-title"
ref="titleEl"
role="textbox"
:aria-label="__('Title')"
:data-placeholder="placeholder"
:contenteditable="!disabled"
- class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base"
+ class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base gl-display-block"
:class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }"
@blur="handleBlur"
@keyup="handleInput"
@@ -55,8 +55,7 @@ export default {
@keydown.meta.u.prevent
@keydown.ctrl.b.prevent
@keydown.meta.b.prevent
+ >{{ title }}</span
>
- {{ title }}
- </div>
</h2>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
index 2753c3fa388..9f9d94ec3c2 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -8,10 +8,14 @@ import {
} from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import {
+ sprintfWorkItem,
+ I18N_WORK_ITEM_DELETE,
+ I18N_WORK_ITEM_ARE_YOU_SURE_DELETE,
+} from '../constants';
export default {
i18n: {
- deleteTask: s__('WorkItem|Delete task'),
enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'),
disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'),
},
@@ -31,6 +35,11 @@ export default {
required: false,
default: null,
},
+ workItemType: {
+ type: String,
+ required: false,
+ default: null,
+ },
canUpdate: {
type: Boolean,
required: false,
@@ -53,6 +62,14 @@ export default {
},
},
emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
+ computed: {
+ i18n() {
+ return {
+ deleteWorkItem: sprintfWorkItem(I18N_WORK_ITEM_DELETE, this.workItemType),
+ areYouSureDelete: sprintfWorkItem(I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, this.workItemType),
+ };
+ },
+ },
methods: {
handleToggleWorkItemConfidentiality() {
this.track('click_toggle_work_item_confidentiality');
@@ -75,6 +92,7 @@ export default {
<div>
<gl-dropdown
icon="ellipsis_v"
+ data-testid="work-item-actions-dropdown"
text-sr-only
:text="__('More actions')"
category="tertiary"
@@ -97,20 +115,18 @@ export default {
v-if="canDelete"
v-gl-modal="'work-item-confirm-delete'"
data-testid="delete-action"
- >{{ $options.i18n.deleteTask }}</gl-dropdown-item
+ >{{ i18n.deleteWorkItem }}</gl-dropdown-item
>
</gl-dropdown>
<gl-modal
modal-id="work-item-confirm-delete"
- :title="$options.i18n.deleteWorkItem"
- :ok-title="$options.i18n.deleteWorkItem"
+ :title="i18n.deleteWorkItem"
+ :ok-title="i18n.deleteWorkItem"
ok-variant="danger"
@ok="handleDeleteWorkItem"
@hide="handleCancelDeleteWorkItem"
>
- {{
- s__('WorkItem|Are you sure you want to delete the task? This action cannot be reversed.')
- }}
+ {{ i18n.areYouSureDelete }}
</gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
index 7342f215b5e..4585426edaa 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -8,6 +8,7 @@ import {
GlButton,
GlDropdownItem,
GlDropdownDivider,
+ GlIntersectionObserver,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -19,7 +20,7 @@ import Tracking from '~/tracking';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
-import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
+import { i18n, TRACKING_CATEGORY_SHOW, DEFAULT_PAGE_SIZE_ASSIGNEES } from '../constants';
function isTokenSelectorElement(el) {
return (
@@ -50,9 +51,9 @@ export default {
InviteMembersTrigger,
GlDropdownItem,
GlDropdownDivider,
+ GlIntersectionObserver,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -80,6 +81,10 @@ export default {
required: false,
default: false,
},
+ fullPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -87,12 +92,15 @@ export default {
searchStarted: false,
localAssignees: this.assignees.map(addClass),
searchKey: '',
- searchUsers: [],
+ users: {
+ nodes: [],
+ },
currentUser: null,
+ isLoadingMore: false,
};
},
apollo: {
- searchUsers: {
+ users: {
query() {
return userSearchQuery;
},
@@ -100,13 +108,14 @@ export default {
return {
fullPath: this.fullPath,
search: this.searchKey,
+ first: DEFAULT_PAGE_SIZE_ASSIGNEES,
};
},
skip() {
return !this.searchStarted;
},
update(data) {
- return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user }));
+ return data.workspace?.users;
},
error() {
this.$emit('error', i18n.fetchError);
@@ -117,6 +126,12 @@ export default {
},
},
computed: {
+ searchUsers() {
+ return this.users.nodes.map((node) => addClass({ ...node, ...node.user }));
+ },
+ pageInfo() {
+ return this.users.pageInfo;
+ },
tracking() {
return {
category: TRACKING_CATEGORY_SHOW,
@@ -131,7 +146,7 @@ export default {
return !this.isEditing ? 'gl-shadow-none!' : '';
},
isLoadingUsers() {
- return this.$apollo.queries.searchUsers.loading;
+ return this.$apollo.queries.users.loading;
},
assigneeText() {
return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length);
@@ -159,6 +174,12 @@ export default {
assigneeIds() {
return this.localAssignees.map(({ id }) => id);
},
+ hasNextPage() {
+ return this.pageInfo?.hasNextPage;
+ },
+ showIntersectionSkeletonLoader() {
+ return this.isLoadingMore && this.dropdownItems.length;
+ },
},
watch: {
assignees: {
@@ -221,6 +242,16 @@ export default {
this.isEditing = true;
this.searchStarted = true;
},
+ async fetchMoreAssignees() {
+ this.isLoadingMore = true;
+ await this.$apollo.queries.users.fetchMore({
+ variables: {
+ after: this.pageInfo.endCursor,
+ first: DEFAULT_PAGE_SIZE_ASSIGNEES,
+ },
+ });
+ this.isLoadingMore = false;
+ },
async focusTokenSelector() {
this.handleFocus();
await this.$nextTick();
@@ -263,7 +294,7 @@ export default {
</script>
<template>
- <div class="form-row gl-mb-5 work-item-assignees gl-relative">
+ <div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap">
<span
class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
data-testid="assignees-title"
@@ -275,7 +306,7 @@ export default {
:container-class="containerClass"
:class="{ 'gl-hover-border-gray-200': canUpdate }"
:dropdown-items="dropdownItems"
- :loading="isLoadingUsers"
+ :loading="isLoadingUsers && !isLoadingMore"
:view-only="!canUpdate"
:allow-clear-all="isEditing"
class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2"
@@ -326,17 +357,32 @@ export default {
<rect width="280" height="20" x="10" y="130" rx="4" />
</gl-skeleton-loader>
</template>
- <template v-if="canInviteMembers" #dropdown-footer>
- <gl-dropdown-divider />
- <gl-dropdown-item @click="closeDropdown">
- <invite-members-trigger
- :display-text="__('Invite members')"
- trigger-element="side-nav"
- icon="plus"
- trigger-source="work-item-assignees-dropdown"
- classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2"
- />
- </gl-dropdown-item>
+ <template #dropdown-footer>
+ <gl-intersection-observer
+ v-if="hasNextPage && !isLoadingUsers"
+ @appear="fetchMoreAssignees"
+ />
+ <gl-skeleton-loader
+ v-if="showIntersectionSkeletonLoader"
+ :height="100"
+ data-testid="next-page-loading"
+ class="gl-text-center gl-py-3"
+ >
+ <rect width="380" height="20" x="10" y="15" rx="4" />
+ <rect width="280" height="20" x="10" y="50" rx="4" />
+ </gl-skeleton-loader>
+ <div v-if="canInviteMembers">
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="closeDropdown">
+ <invite-members-trigger
+ :display-text="__('Invite members')"
+ trigger-element="side-nav"
+ icon="plus"
+ trigger-source="work-item-assignees-dropdown"
+ classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2"
+ />
+ </gl-dropdown-item>
+ </div>
</template>
</gl-token-selector>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index cf59789ce2d..c2e4a50fe31 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -8,7 +8,7 @@ import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import workItemQuery from '../graphql/work_item.query.graphql';
-import updateWorkItemWidgetsMutation from '../graphql/update_work_item_widgets.mutation.graphql';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
export default {
@@ -21,12 +21,15 @@ export default {
MarkdownField,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
+ fullPath: {
+ type: String,
+ required: true,
+ },
},
markdownDocsPath: helpPagePath('user/markdown'),
data() {
@@ -139,9 +142,9 @@ export default {
this.track('updated_description');
const {
- data: { workItemUpdateWidgets },
+ data: { workItemUpdate },
} = await this.$apollo.mutate({
- mutation: updateWorkItemWidgetsMutation,
+ mutation: updateWorkItemMutation,
variables: {
input: {
id: this.workItem.id,
@@ -152,8 +155,8 @@ export default {
},
});
- if (workItemUpdateWidgets.errors?.length) {
- throw new Error(workItemUpdateWidgets.errors[0]);
+ if (workItemUpdate.errors?.length) {
+ throw new Error(workItemUpdate.errors[0]);
}
this.isEditing = false;
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index a5580c14a7a..3d25df9fcb8 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -16,12 +16,14 @@ import {
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
WIDGET_TYPE_DESCRIPTION,
+ WIDGET_TYPE_START_AND_DUE_DATE,
WIDGET_TYPE_WEIGHT,
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_VIEWED_STORAGE_KEY,
} from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
+import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
@@ -30,9 +32,9 @@ import WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
import WorkItemDescription from './work_item_description.vue';
+import WorkItemDueDate from './work_item_due_date.vue';
import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
-import WorkItemWeight from './work_item_weight.vue';
import WorkItemInformation from './work_item_information.vue';
export default {
@@ -50,10 +52,11 @@ export default {
WorkItemAssignees,
WorkItemActions,
WorkItemDescription,
+ WorkItemDueDate,
WorkItemLabels,
WorkItemTitle,
WorkItemState,
- WorkItemWeight,
+ WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'),
WorkItemInformation,
LocalStorageSync,
WorkItemTypeIcon,
@@ -98,14 +101,36 @@ export default {
error() {
this.error = this.$options.i18n.fetchError;
},
- subscribeToMore: {
- document: workItemTitleSubscription,
- variables() {
- return {
- issuableId: this.workItemId,
- };
- },
+ result() {
+ if (!this.isModal) {
+ const path = this.workItem.project?.fullPath
+ ? ` · ${this.workItem.project.fullPath}`
+ : '';
+
+ document.title = `${this.workItem.title} · ${this.workItem?.workItemType?.name}${path}`;
+ }
},
+ subscribeToMore: [
+ {
+ document: workItemTitleSubscription,
+ variables() {
+ return {
+ issuableId: this.workItemId,
+ };
+ },
+ },
+ {
+ document: workItemDatesSubscription,
+ variables() {
+ return {
+ issuableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemDueDate;
+ },
+ },
+ ],
},
},
computed: {
@@ -121,6 +146,9 @@ export default {
canDelete() {
return this.workItem?.userPermissions?.deleteWorkItem;
},
+ fullPath() {
+ return this.workItem?.project.fullPath;
+ },
workItemsMvc2Enabled() {
return this.glFeatures.workItemsMvc2;
},
@@ -133,6 +161,11 @@ export default {
workItemLabels() {
return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
},
+ workItemDueDate() {
+ return this.workItem?.widgets?.find(
+ (widget) => widget.type === WIDGET_TYPE_START_AND_DUE_DATE,
+ );
+ },
workItemWeight() {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
},
@@ -276,11 +309,12 @@ export default {
<work-item-actions
v-if="canUpdate || canDelete"
:work-item-id="workItem.id"
+ :work-item-type="workItemType"
:can-delete="canDelete"
:can-update="canUpdate"
:is-confidential="workItem.confidential"
:is-parent-confidential="parentWorkItemConfidentiality"
- @deleteWorkItem="$emit('deleteWorkItem')"
+ @deleteWorkItem="$emit('deleteWorkItem', workItemType)"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="error = $event"
/>
@@ -317,21 +351,32 @@ export default {
:can-update="canUpdate"
@error="error = $event"
/>
+ <work-item-assignees
+ v-if="workItemAssignees"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :assignees="workItemAssignees.assignees.nodes"
+ :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
+ :work-item-type="workItemType"
+ :can-invite-members="workItemAssignees.canInviteMembers"
+ :full-path="fullPath"
+ @error="error = $event"
+ />
<template v-if="workItemsMvc2Enabled">
- <work-item-assignees
- v-if="workItemAssignees"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :assignees="workItemAssignees.assignees.nodes"
- :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
- :work-item-type="workItemType"
- :can-invite-members="workItemAssignees.canInviteMembers"
- @error="error = $event"
- />
<work-item-labels
v-if="workItemLabels"
:work-item-id="workItem.id"
:can-update="canUpdate"
+ :full-path="fullPath"
+ @error="error = $event"
+ />
+ <work-item-due-date
+ v-if="workItemDueDate"
+ :can-update="canUpdate"
+ :due-date="workItemDueDate.dueDate"
+ :start-date="workItemDueDate.startDate"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
@error="error = $event"
/>
</template>
@@ -347,6 +392,7 @@ export default {
<work-item-description
v-if="hasDescriptionWidget"
:work-item-id="workItem.id"
+ :full-path="fullPath"
class="gl-pt-5"
@error="error = $event"
/>
diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue
new file mode 100644
index 00000000000..05f8fa8f5e1
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue
@@ -0,0 +1,257 @@
+<script>
+import { GlButton, GlDatepicker, GlFormGroup } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { getDateWithUTC, newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import {
+ I18N_WORK_ITEM_ERROR_UPDATING,
+ sprintfWorkItem,
+ TRACKING_CATEGORY_SHOW,
+} from '~/work_items/constants';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+
+const nullObjectDate = new Date(0);
+
+export default {
+ i18n: {
+ addDueDate: s__('WorkItem|Add due date'),
+ addStartDate: s__('WorkItem|Add start date'),
+ dates: s__('WorkItem|Dates'),
+ dueDate: s__('WorkItem|Due date'),
+ none: s__('WorkItem|None'),
+ startDate: s__('WorkItem|Start date'),
+ },
+ dueDateInputId: 'due-date-input',
+ startDateInputId: 'start-date-input',
+ components: {
+ GlButton,
+ GlDatepicker,
+ GlFormGroup,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ dueDate: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ startDate: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dirtyDueDate: null,
+ dirtyStartDate: null,
+ isUpdating: false,
+ showDueDateInput: false,
+ showStartDateInput: false,
+ };
+ },
+ computed: {
+ datesUnchanged() {
+ const dirtyDueDate = this.dirtyDueDate || nullObjectDate;
+ const dirtyStartDate = this.dirtyStartDate || nullObjectDate;
+ const dueDate = this.dueDate ? newDateAsLocaleTime(this.dueDate) : nullObjectDate;
+ const startDate = this.startDate ? newDateAsLocaleTime(this.startDate) : nullObjectDate;
+ return (
+ dirtyDueDate.getTime() === dueDate.getTime() &&
+ dirtyStartDate.getTime() === startDate.getTime()
+ );
+ },
+ isDatepickerDisabled() {
+ return !this.canUpdate || this.isUpdating;
+ },
+ isReadonlyWithOnlyDueDate() {
+ return !this.canUpdate && this.dueDate && !this.startDate;
+ },
+ isReadonlyWithOnlyStartDate() {
+ return !this.canUpdate && !this.dueDate && this.startDate;
+ },
+ isReadonlyWithNoDates() {
+ return !this.canUpdate && !this.dueDate && !this.startDate;
+ },
+ labelClass() {
+ return this.isReadonlyWithNoDates ? 'gl-align-self-center gl-pb-0!' : 'gl-mt-3 gl-pb-0!';
+ },
+ showDueDateButton() {
+ return this.canUpdate && !this.showDueDateInput;
+ },
+ showStartDateButton() {
+ return this.canUpdate && !this.showStartDateInput;
+ },
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_dates',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ },
+ watch: {
+ dueDate: {
+ handler(newDueDate) {
+ this.dirtyDueDate = newDateAsLocaleTime(newDueDate);
+ this.showDueDateInput = Boolean(newDueDate);
+ },
+ immediate: true,
+ },
+ startDate: {
+ handler(newStartDate) {
+ this.dirtyStartDate = newDateAsLocaleTime(newStartDate);
+ this.showStartDateInput = Boolean(newStartDate);
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ clearDueDatePicker() {
+ this.dirtyDueDate = null;
+ this.showDueDateInput = false;
+ this.updateDates();
+ },
+ clearStartDatePicker() {
+ this.dirtyStartDate = null;
+ this.showStartDateInput = false;
+ this.updateDates();
+ },
+ async clickShowDueDate() {
+ this.showDueDateInput = true;
+ await this.$nextTick();
+ this.$refs.dueDatePicker.calendar.show();
+ },
+ async clickShowStartDate() {
+ this.showStartDateInput = true;
+ await this.$nextTick();
+ this.$refs.startDatePicker.calendar.show();
+ },
+ handleStartDateInput() {
+ if (this.dirtyDueDate && this.dirtyStartDate > this.dirtyDueDate) {
+ this.dirtyDueDate = this.dirtyStartDate;
+ this.clickShowDueDate();
+ return;
+ }
+
+ this.updateDates();
+ },
+ updateDates() {
+ if (!this.canUpdate || this.datesUnchanged) {
+ return;
+ }
+
+ this.track('updated_dates');
+
+ this.isUpdating = true;
+
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ startAndDueDateWidget: {
+ dueDate: getDateWithUTC(this.dirtyDueDate),
+ startDate: getDateWithUTC(this.dirtyStartDate),
+ },
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.workItemUpdate.errors.length) {
+ throw new Error(data.workItemUpdate.errors.join('; '));
+ }
+ })
+ .catch((error) => {
+ const message = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+ this.$emit('error', message);
+ Sentry.captureException(error);
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ class="work-item-due-date"
+ :label="$options.i18n.dates"
+ :label-class="labelClass"
+ label-cols="3"
+ label-cols-lg="2"
+ >
+ <span v-if="isReadonlyWithNoDates" class="gl-text-gray-400 gl-ml-4">
+ {{ $options.i18n.none }}
+ </span>
+ <div v-else class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <gl-form-group
+ class="gl-display-flex gl-align-items-center gl-m-0"
+ :class="{ 'gl-ml-n3': isReadonlyWithOnlyDueDate }"
+ :label="$options.i18n.startDate"
+ :label-for="$options.startDateInputId"
+ :label-sr-only="!showStartDateInput"
+ label-class="gl-flex-shrink-0 gl-text-secondary gl-font-weight-normal! gl-pb-0! gl-ml-4 gl-mr-3"
+ >
+ <gl-datepicker
+ v-if="showStartDateInput"
+ ref="startDatePicker"
+ v-model="dirtyStartDate"
+ container="body"
+ :disabled="isDatepickerDisabled"
+ :input-id="$options.startDateInputId"
+ show-clear-button
+ :target="null"
+ @clear="clearStartDatePicker"
+ @close="handleStartDateInput"
+ />
+ <gl-button v-if="showStartDateButton" category="tertiary" @click="clickShowStartDate">
+ {{ $options.i18n.addStartDate }}
+ </gl-button>
+ </gl-form-group>
+ <gl-form-group
+ v-if="!isReadonlyWithOnlyStartDate"
+ class="gl-display-flex gl-align-items-center gl-m-0"
+ :class="{ 'gl-ml-n3': isReadonlyWithOnlyDueDate }"
+ :label="$options.i18n.dueDate"
+ :label-for="$options.dueDateInputId"
+ :label-sr-only="!showDueDateInput"
+ label-class="gl-flex-shrink-0 gl-text-secondary gl-font-weight-normal! gl-pb-0! gl-ml-4 gl-mr-3"
+ >
+ <gl-datepicker
+ v-if="showDueDateInput"
+ ref="dueDatePicker"
+ v-model="dirtyDueDate"
+ container="body"
+ :disabled="isDatepickerDisabled"
+ :input-id="$options.dueDateInputId"
+ :min-date="dirtyStartDate"
+ show-clear-button
+ :target="null"
+ @clear="clearDueDatePicker"
+ @close="updateDates"
+ />
+ <gl-button v-if="showDueDateButton" category="tertiary" @click="clickShowDueDate">
+ {{ $options.i18n.addDueDate }}
+ </gl-button>
+ </gl-form-group>
+ </div>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue
index 2ff7ba169ea..ce75cc98a75 100644
--- a/app/assets/javascripts/work_items/components/work_item_information.vue
+++ b/app/assets/javascripts/work_items/components/work_item_information.vue
@@ -5,16 +5,14 @@ import { helpPagePath } from '~/helpers/help_page_helper';
export default {
i18n: {
- learnTasksButtonText: s__('WorkItem|Learn about tasks'),
- workItemsText: s__('WorkItem|work items'),
+ learnTasksLinkText: s__('WorkItem|Learn about tasks.'),
tasksInformationTitle: s__('WorkItem|Introducing tasks'),
tasksInformationBody: s__(
- 'WorkItem|A task provides the ability to break down your work into smaller pieces tied to an issue. Tasks are the first items using our new %{workItemsLink} objects. Additional work item types will be coming soon.',
+ 'WorkItem|Use tasks to break down your work in an issue into smaller pieces. %{learnMoreLink}',
),
},
helpPageLinks: {
tasksDocLinkPath: helpPagePath('user/tasks'),
- workItemsLinkPath: helpPagePath(`development/work_items`),
},
components: {
GlAlert,
@@ -38,16 +36,14 @@ export default {
v-if="showInfoBanner"
variant="tip"
:title="$options.i18n.tasksInformationTitle"
- :primary-button-link="$options.helpPageLinks.tasksDocLinkPath"
- :primary-button-text="$options.i18n.learnTasksButtonText"
data-testid="work-item-information"
class="gl-mt-3"
@dismiss="$emit('work-item-banner-dismissed')"
>
<gl-sprintf :message="$options.i18n.tasksInformationBody">
- <template #workItemsLink>
- <gl-link :href="$options.helpPageLinks.workItemsLinkPath">{{
- $options.i18n.workItemsText
+ <template #learnMoreLink>
+ <gl-link :href="$options.helpPageLinks.tasksDocLinkPath">{{
+ $options.i18n.learnTasksLinkText
}}</gl-link>
</template>
></gl-sprintf
diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue
index e73488bbd70..b8b5198be57 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -31,7 +31,6 @@ export default {
LabelItem,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -41,6 +40,10 @@ export default {
type: Boolean,
required: true,
},
+ fullPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -189,7 +192,7 @@ export default {
</script>
<template>
- <div class="form-row gl-mb-5 work-item-labels gl-relative">
+ <div class="form-row gl-mb-5 work-item-labels gl-relative gl-flex-nowrap">
<span
class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
data-testid="labels-title"
@@ -216,7 +219,7 @@ export default {
class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-400 gl-pr-4 gl-top-2"
data-testid="empty-state"
>
- <span v-if="canUpdate" class="gl-ml-2">{{ __('Select labels') }}</span>
+ <span v-if="canUpdate" class="gl-ml-2">{{ __('Add labels') }}</span>
<span v-else class="gl-ml-2">{{ __('None') }}</span>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index 86f03583ea3..8f31b07b6a3 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
-import { createApolloProvider } from '../../graphql/provider';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import WorkItemLinks from './work_item_links.vue';
Vue.use(GlToast);
@@ -16,18 +16,19 @@ export default function initWorkItemLinks() {
return;
}
- const { projectPath, wiHasIssueWeightsFeature } = workItemLinksRoot.dataset;
+ const { projectPath, wiHasIssueWeightsFeature, iid } = workItemLinksRoot.dataset;
// eslint-disable-next-line no-new
new Vue({
el: workItemLinksRoot,
name: 'WorkItemLinksRoot',
- apolloProvider: createApolloProvider(),
+ apolloProvider,
components: {
- workItemLinks: WorkItemLinks,
+ WorkItemLinks,
},
provide: {
projectPath,
+ iid,
fullPath: projectPath,
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
},
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
new file mode 100644
index 00000000000..34874908f9b
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+
+import { __ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
+
+import { STATE_OPEN } from '../../constants';
+import WorkItemLinksMenu from './work_item_links_menu.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ RichTimestampTooltip,
+ WorkItemLinksMenu,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: true,
+ },
+ issuableGid: {
+ type: String,
+ required: true,
+ },
+ childItem: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ isItemOpen() {
+ return this.childItem.state === STATE_OPEN;
+ },
+ iconClass() {
+ return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500';
+ },
+ iconName() {
+ return this.isItemOpen ? 'issue-open-m' : 'issue-close';
+ },
+ stateTimestamp() {
+ return this.isItemOpen ? this.childItem.createdAt : this.childItem.closedAt;
+ },
+ stateTimestampTypeText() {
+ return this.isItemOpen ? __('Created') : __('Closed');
+ },
+ childPath() {
+ return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32"
+ data-testid="links-child"
+ >
+ <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1">
+ <span :id="`stateIcon-${childItem.id}`" class="gl-mr-3" data-testid="item-status-icon">
+ <gl-icon :name="iconName" :class="iconClass" :aria-label="stateTimestampTypeText" />
+ </span>
+ <rich-timestamp-tooltip
+ :target="`stateIcon-${childItem.id}`"
+ :raw-timestamp="stateTimestamp"
+ :timestamp-type-text="stateTimestampTypeText"
+ />
+ <gl-icon
+ v-if="childItem.confidential"
+ v-gl-tooltip.top
+ name="eye-slash"
+ class="gl-mr-2 gl-text-orange-500"
+ data-testid="confidential-icon"
+ :aria-label="__('Confidential')"
+ :title="__('Confidential')"
+ />
+ <gl-button
+ :href="childPath"
+ category="tertiary"
+ variant="link"
+ class="gl-text-truncate gl-max-w-80 gl-text-black-normal!"
+ @click="$emit('click', childItem.id, $event)"
+ @mouseover="$emit('mouseover', childItem.id, $event)"
+ @mouseout="$emit('mouseout', childItem.id, $event)"
+ >
+ {{ childItem.title }}
+ </gl-button>
+ </div>
+ <div
+ v-if="canUpdate"
+ class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center"
+ >
+ <work-item-links-menu
+ :work-item-id="childItem.id"
+ :parent-work-item-id="issuableGid"
+ data-testid="links-menu"
+ @removeChild="$emit('remove', childItem.id)"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index 534ebabee08..840fd910272 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -5,22 +5,17 @@ import { s__ } from '~/locale';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
import { isMetaKey } from '~/lib/utils/common_utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
-import SidebarEventHub from '~/sidebar/event_hub';
-import {
- STATE_OPEN,
- WIDGET_ICONS,
- WORK_ITEM_STATUS_TEXT,
- WIDGET_TYPE_HIERARCHY,
-} from '../../constants';
+import { WIDGET_ICONS, WORK_ITEM_STATUS_TEXT, WIDGET_TYPE_HIERARCHY } from '../../constants';
import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import workItemQuery from '../../graphql/work_item.query.graphql';
import WorkItemDetailModal from '../work_item_detail_modal.vue';
+import WorkItemLinkChild from './work_item_link_child.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
-import WorkItemLinksMenu from './work_item_links_menu.vue';
export default {
components: {
@@ -28,14 +23,14 @@ export default {
GlIcon,
GlAlert,
GlLoadingIcon,
+ WorkItemLinkChild,
WorkItemLinksForm,
- WorkItemLinksMenu,
WorkItemDetailModal,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['projectPath'],
+ inject: ['projectPath', 'iid'],
props: {
workItemId: {
type: String,
@@ -63,6 +58,18 @@ export default {
this.error = e.message || this.$options.i18n.fetchError;
},
},
+ parentIssue: {
+ query: issueConfidentialQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: String(this.iid),
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable;
+ },
+ },
},
data() {
return {
@@ -72,9 +79,13 @@ export default {
activeToast: null,
prefetchedWorkItem: null,
error: undefined,
+ parentIssue: null,
};
},
computed: {
+ confidential() {
+ return this.parentIssue?.confidential || this.workItem?.confidential || false;
+ },
children() {
return (
this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
@@ -84,9 +95,6 @@ export default {
canUpdate() {
return this.workItem?.userPermissions.updateWorkItem || false;
},
- confidential() {
- return this.workItem?.confidential || false;
- },
// Only used for children for now but should be extended later to support parents and siblings
isChildrenEmpty() {
return this.children?.length === 0;
@@ -95,9 +103,7 @@ export default {
return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
},
toggleLabel() {
- return this.isOpen
- ? s__('WorkItem|Collapse child items')
- : s__('WorkItem|Expand child items');
+ return this.isOpen ? s__('WorkItem|Collapse tasks') : s__('WorkItem|Expand tasks');
},
issuableGid() {
return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null;
@@ -112,22 +118,7 @@ export default {
return this.isLoading && this.children.length === 0 ? '...' : this.children.length;
},
},
- mounted() {
- SidebarEventHub.$on('confidentialityUpdated', this.refetchWorkItems);
- },
- destroyed() {
- SidebarEventHub.$off('confidentialityUpdated', this.refetchWorkItems);
- },
methods: {
- refetchWorkItems() {
- this.$apollo.queries.workItem.refetch();
- },
- iconClass(state) {
- return state === STATE_OPEN ? 'gl-text-green-500' : 'gl-text-blue-500';
- },
- iconName(state) {
- return state === STATE_OPEN ? 'issue-open-m' : 'issue-close';
- },
toggle() {
this.isOpen = !this.isOpen;
},
@@ -169,9 +160,6 @@ export default {
replace: true,
});
},
- childPath(childItemId) {
- return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(childItemId)}`;
- },
toggleChildFromCache(workItem, childId, store) {
const sourceData = store.readQuery({
query: getWorkItemLinksQuery,
@@ -242,14 +230,12 @@ export default {
},
},
i18n: {
- title: s__('WorkItem|Child items'),
- fetchError: s__(
- 'WorkItem|Something went wrong when fetching the items list. Please refresh this page.',
- ),
+ title: s__('WorkItem|Tasks'),
+ fetchError: s__('WorkItem|Something went wrong when fetching tasks. Please refresh this page.'),
emptyStateMessage: s__(
- 'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!',
+ 'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.',
),
- addChildButtonLabel: s__('WorkItem|Add a task'),
+ addChildButtonLabel: s__('WorkItem|Add'),
},
WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK,
WORK_ITEM_STATUS_TEXT,
@@ -257,7 +243,10 @@ export default {
</script>
<template>
- <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10">
+ <div
+ class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-5"
+ data-testid="work-item-links"
+ >
<div
class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between"
:class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
@@ -319,48 +308,18 @@ export default {
@cancel="hideAddForm"
@addWorkItemChild="addChild"
/>
- <div
+ <work-item-link-child
v-for="child in children"
:key="child.id"
- class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32"
- data-testid="links-child"
- >
- <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1">
- <gl-icon
- :name="iconName(child.state)"
- class="gl-mr-3"
- :class="iconClass(child.state)"
- />
- <gl-icon
- v-if="child.confidential"
- v-gl-tooltip.top
- name="eye-slash"
- class="gl-mr-2 gl-text-orange-500"
- data-testid="confidential-icon"
- :title="__('Confidential')"
- />
- <gl-button
- :href="childPath(child.id)"
- category="tertiary"
- variant="link"
- class="gl-text-truncate gl-max-w-80 gl-text-black-normal!"
- @click="openChild(child.id, $event)"
- @mouseover="prefetchWorkItem(child.id)"
- @mouseout="clearPrefetching"
- >
- {{ child.title }}
- </gl-button>
- </div>
- <div class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center">
- <work-item-links-menu
- v-if="canUpdate"
- :work-item-id="child.id"
- :parent-work-item-id="issuableGid"
- data-testid="links-menu"
- @removeChild="removeChild(child.id)"
- />
- </div>
- </div>
+ :project-path="projectPath"
+ :can-update="canUpdate"
+ :issuable-gid="issuableGid"
+ :child-item="child"
+ @click="openChild"
+ @mouseover="prefetchWorkItem"
+ @mouseout="clearPrefetching"
+ @remove="removeChild"
+ />
<work-item-detail-modal
ref="modal"
:work-item-id="activeChildId"
diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state.vue
index 080d4025cc3..3880ae25c8c 100644
--- a/app/assets/javascripts/work_items/components/work_item_state.vue
+++ b/app/assets/javascripts/work_items/components/work_item_state.vue
@@ -2,7 +2,8 @@
import * as Sentry from '@sentry/browser';
import Tracking from '~/tracking';
import {
- i18n,
+ sprintfWorkItem,
+ I18N_WORK_ITEM_ERROR_UPDATING,
STATE_OPEN,
STATE_CLOSED,
STATE_EVENT_CLOSE,
@@ -93,7 +94,9 @@ export default {
throw new Error(errors[0]);
}
} catch (error) {
- this.$emit('error', i18n.updateError);
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+
+ this.$emit('error', msg);
Sentry.captureException(error);
}
diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue
index cd5cc3894f6..c52a6854fad 100644
--- a/app/assets/javascripts/work_items/components/work_item_title.vue
+++ b/app/assets/javascripts/work_items/components/work_item_title.vue
@@ -1,7 +1,11 @@
<script>
import * as Sentry from '@sentry/browser';
import Tracking from '~/tracking';
-import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
+import {
+ sprintfWorkItem,
+ I18N_WORK_ITEM_ERROR_UPDATING,
+ TRACKING_CATEGORY_SHOW,
+} from '../constants';
import { getUpdateWorkItemMutation } from './update_work_item';
import ItemTitle from './item_title.vue';
@@ -78,7 +82,8 @@ export default {
throw new Error(errors[0]);
}
} catch (error) {
- this.$emit('error', i18n.updateError);
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+ this.$emit('error', msg);
Sentry.captureException(error);
}
diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
index fd914fa350b..31e75663055 100644
--- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue
+++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
@@ -1,11 +1,14 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { WORK_ITEMS_TYPE_MAP } from '../constants';
export default {
components: {
GlIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
workItemType: {
type: String,
@@ -22,6 +25,11 @@ export default {
required: false,
default: '',
},
+ showTooltipOnHover: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
iconName() {
@@ -32,13 +40,21 @@ export default {
workItemTypeName() {
return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name;
},
+ workItemTooltipTitle() {
+ return this.showTooltipOnHover ? this.workItemTypeName : '';
+ },
},
};
</script>
<template>
<span>
- <gl-icon :name="iconName" class="gl-mr-2" />
+ <gl-icon
+ v-gl-tooltip.hover="showTooltipOnHover"
+ :name="iconName"
+ :title="workItemTooltipTitle"
+ class="gl-mr-2 gl-text-gray-500"
+ />
<span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span>
</span>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue
deleted file mode 100644
index b0ad7c97bb1..00000000000
--- a/app/assets/javascripts/work_items/components/work_item_weight.vue
+++ /dev/null
@@ -1,162 +0,0 @@
-<script>
-import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
-import * as Sentry from '@sentry/browser';
-import { __ } from '~/locale';
-import Tracking from '~/tracking';
-import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
-import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
-
-/* eslint-disable @gitlab/require-i18n-strings */
-const allowedKeys = [
- 'Alt',
- 'ArrowDown',
- 'ArrowLeft',
- 'ArrowRight',
- 'ArrowUp',
- 'Backspace',
- 'Control',
- 'Delete',
- 'End',
- 'Enter',
- 'Home',
- 'Meta',
- 'PageDown',
- 'PageUp',
- 'Tab',
- '0',
- '1',
- '2',
- '3',
- '4',
- '5',
- '6',
- '7',
- '8',
- '9',
-];
-/* eslint-enable @gitlab/require-i18n-strings */
-
-export default {
- inputId: 'weight-widget-input',
- components: {
- GlForm,
- GlFormGroup,
- GlFormInput,
- },
- mixins: [Tracking.mixin()],
- inject: ['hasIssueWeightsFeature'],
- props: {
- canUpdate: {
- type: Boolean,
- required: false,
- default: false,
- },
- weight: {
- type: Number,
- required: false,
- default: undefined,
- },
- workItemId: {
- type: String,
- required: true,
- },
- workItemType: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- isEditing: false,
- };
- },
- computed: {
- placeholder() {
- return this.canUpdate && this.isEditing ? __('Enter a number') : __('None');
- },
- tracking() {
- return {
- category: TRACKING_CATEGORY_SHOW,
- label: 'item_weight',
- property: `type_${this.workItemType}`,
- };
- },
- type() {
- return this.canUpdate && this.isEditing ? 'number' : 'text';
- },
- },
- methods: {
- blurInput() {
- this.$refs.input.$el.blur();
- },
- handleFocus() {
- this.isEditing = true;
- },
- handleKeydown(event) {
- if (!allowedKeys.includes(event.key)) {
- event.preventDefault();
- }
- },
- updateWeight(event) {
- if (!this.canUpdate) return;
- this.isEditing = false;
-
- const weight = Number(event.target.value);
- if (this.weight === weight) {
- return;
- }
-
- this.track('updated_weight');
- this.$apollo
- .mutate({
- mutation: updateWorkItemMutation,
- variables: {
- input: {
- id: this.workItemId,
- weightWidget: {
- weight: event.target.value === '' ? null : weight,
- },
- },
- },
- })
- .then(({ data }) => {
- if (data.workItemUpdate.errors.length) {
- throw new Error(data.workItemUpdate.errors.join('\n'));
- }
- })
- .catch((error) => {
- this.$emit('error', i18n.updateError);
- Sentry.captureException(error);
- });
- },
- },
-};
-</script>
-
-<template>
- <gl-form v-if="hasIssueWeightsFeature" @submit.prevent="blurInput">
- <gl-form-group
- class="gl-align-items-center"
- :label="__('Weight')"
- :label-for="$options.inputId"
- label-class="gl-pb-0! gl-overflow-wrap-break"
- label-cols="3"
- label-cols-lg="2"
- >
- <gl-form-input
- :id="$options.inputId"
- ref="input"
- min="0"
- :placeholder="placeholder"
- :readonly="!canUpdate"
- size="sm"
- :type="type"
- :value="weight"
- @blur="updateWeight"
- @focus="handleFocus"
- @keydown="handleKeydown"
- @keydown.exact.esc.stop="blurInput"
- />
- </gl-form-group>
- </gl-form>
-</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index a2aea3cd327..78219e62d01 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -1,4 +1,5 @@
-import { s__ } from '~/locale';
+import { s__, sprintf } from '~/locale';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
export const STATE_OPEN = 'OPEN';
export const STATE_CLOSED = 'CLOSED';
@@ -13,6 +14,7 @@ export const TASK_TYPE_NAME = 'Task';
export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
export const WIDGET_TYPE_LABELS = 'LABELS';
+export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY';
export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner';
@@ -31,6 +33,30 @@ export const i18n = {
),
};
+export const I18N_WORK_ITEM_ERROR_CREATING = s__(
+ 'WorkItem|Something went wrong when creating %{workItemType}. Please try again.',
+);
+export const I18N_WORK_ITEM_ERROR_UPDATING = s__(
+ 'WorkItem|Something went wrong while updating the %{workItemType}. Please try again.',
+);
+export const I18N_WORK_ITEM_ERROR_DELETING = s__(
+ 'WorkItem|Something went wrong when deleting the %{workItemType}. Please try again.',
+);
+export const I18N_WORK_ITEM_DELETE = s__('WorkItem|Delete %{workItemType}');
+export const I18N_WORK_ITEM_ARE_YOU_SURE_DELETE = s__(
+ 'WorkItem|Are you sure you want to delete the %{workItemType}? This action cannot be reversed.',
+);
+export const I18N_WORK_ITEM_DELETED = s__('WorkItem|%{workItemType} deleted');
+
+export const sprintfWorkItem = (msg, workItemTypeArg) => {
+ const workItemType = workItemTypeArg || s__('WorkItem|Work item');
+ return capitalizeFirstCharacter(
+ sprintf(msg, {
+ workItemType: workItemType.toLocaleLowerCase(),
+ }),
+ );
+};
+
export const WIDGET_ICONS = {
TASK: 'issue-type-task',
};
@@ -62,3 +88,5 @@ export const WORK_ITEMS_TYPE_MAP = {
name: s__('WorkItem|Requirements'),
},
};
+
+export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10;
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 4cc23fa0071..1228c876a55 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,4 +1,4 @@
-#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./work_item.fragment.graphql"
mutation createWorkItem($input: WorkItemCreateInput!) {
workItemCreate(input: $input) {
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
index 1f98cd4fa2b..ccfe62cc585 100644
--- a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./work_item.fragment.graphql"
mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) {
workItemCreateFromTask(input: $input) {
diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
index 790b8e60b6a..43c92cf89ec 100644
--- a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./work_item.fragment.graphql"
mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) {
localUpdateWorkItem(input: $input) @client {
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 0a887fcfc00..25eb8099251 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,4 +1,4 @@
-#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./work_item.fragment.graphql"
mutation workItemUpdate($input: WorkItemUpdateInput!) {
workItemUpdate(input: $input) {
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
index fad5a9fa5bc..ad861a60d15 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./work_item.fragment.graphql"
mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) {
workItemUpdate: workItemUpdateTask(input: $input) {
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql
deleted file mode 100644
index 6a94c96b347..00000000000
--- a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql
+++ /dev/null
@@ -1,10 +0,0 @@
-#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
-
-mutation workItemUpdateWidgets($input: WorkItemUpdateWidgetsInput!) {
- workItemUpdateWidgets(input: $input) {
- workItem {
- ...WorkItem
- }
- errors
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
index e8ef27ec778..f4c77ed2ec0 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item_widgets.fragment.graphql"
fragment WorkItem on WorkItem {
id
@@ -6,6 +7,12 @@ fragment WorkItem on WorkItem {
state
description
confidential
+ createdAt
+ closedAt
+ project {
+ id
+ fullPath
+ }
workItemType {
id
name
@@ -16,34 +23,6 @@ fragment WorkItem on WorkItem {
updateWorkItem
}
widgets {
- ... on WorkItemWidgetDescription {
- type
- description
- descriptionHtml
- }
- ... on WorkItemWidgetAssignees {
- type
- allowsMultipleAssignees
- canInviteMembers
- assignees {
- nodes {
- ...User
- }
- }
- }
- ... on WorkItemWidgetHierarchy {
- type
- parent {
- id
- iid
- title
- confidential
- }
- children {
- nodes {
- id
- }
- }
- }
+ ...WorkItemWidgets
}
}
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 a9f7b714551..276061af193 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
-#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./work_item.fragment.graphql"
query workItem($id: WorkItemID!) {
workItem(id: $id) {
diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql
new file mode 100644
index 00000000000..7e045fdf431
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql
@@ -0,0 +1,13 @@
+subscription issuableDatesUpdated($issuableId: IssuableID!) {
+ issuableDatesUpdated(issuableId: $issuableId) {
+ ... on WorkItem {
+ id
+ widgets {
+ ... on WorkItemWidgetStartAndDueDate {
+ dueDate
+ startDate
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
index df62ca1c143..7b63d9c7ca3 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
@@ -1,4 +1,4 @@
-query workItemQuery($id: WorkItemID!) {
+query workItemLinksQuery($id: WorkItemID!) {
workItem(id: $id) {
id
workItemType {
@@ -26,6 +26,8 @@ query workItemQuery($id: WorkItemID!) {
}
title
state
+ createdAt
+ closedAt
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
new file mode 100644
index 00000000000..3005069f59a
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
@@ -0,0 +1,36 @@
+fragment WorkItemWidgets on WorkItemWidget {
+ ... on WorkItemWidgetDescription {
+ type
+ description
+ descriptionHtml
+ }
+ ... on WorkItemWidgetAssignees {
+ type
+ allowsMultipleAssignees
+ canInviteMembers
+ assignees {
+ nodes {
+ ...User
+ }
+ }
+ }
+ ... on WorkItemWidgetStartAndDueDate {
+ type
+ dueDate
+ startDate
+ }
+ ... on WorkItemWidgetHierarchy {
+ type
+ parent {
+ id
+ iid
+ title
+ confidential
+ }
+ children {
+ nodes {
+ id
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 6437df597b4..bb4c7052238 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -1,8 +1,8 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import App from './components/app.vue';
import { createRouter } from './router';
-import { createApolloProvider } from './graphql/provider';
export const initWorkItemsRoot = () => {
const el = document.querySelector('#js-work-items');
@@ -12,7 +12,7 @@ export const initWorkItemsRoot = () => {
el,
name: 'WorkItemsRoot',
router: createRouter(el.dataset.fullPath),
- apolloProvider: createApolloProvider(),
+ apolloProvider,
provide: {
fullPath,
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
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 482da5419c6..3b7257591e2 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -1,7 +1,9 @@
<script>
import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { getPreferredLocales, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql';
@@ -10,7 +12,6 @@ import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.
import ItemTitle from '../components/item_title.vue';
export default {
- createErrorText: s__('WorkItem|Something went wrong when creating a task. Please try again'),
fetchTypesErrorText: s__(
'WorkItem|Something went wrong when fetching work item types. Please try again',
),
@@ -69,7 +70,7 @@ export default {
update(data) {
return data.workspace?.workItemTypes?.nodes.map((node) => ({
value: node.id,
- text: node.name,
+ text: capitalizeFirstCharacter(node.name.toLocaleLowerCase(getPreferredLocales()[0])),
}));
},
error() {
@@ -78,15 +79,19 @@ export default {
},
},
computed: {
- dropdownButtonText() {
- return this.selectedWorkItemType?.name || s__('WorkItem|Type');
- },
formOptions() {
return [{ value: null, text: s__('WorkItem|Select type') }, ...this.workItemTypes];
},
isButtonDisabled() {
return this.title.trim().length === 0 || !this.selectedWorkItemType;
},
+ createErrorText() {
+ const workItemType = this.workItemTypes.find(
+ (item) => item.value === this.selectedWorkItemType,
+ )?.text;
+
+ return sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemType);
+ },
},
methods: {
async createWorkItem() {
@@ -128,7 +133,7 @@ export default {
} = response;
this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } });
} catch {
- this.error = this.$options.createErrorText;
+ this.error = this.createErrorText;
}
},
async createWorkItemFromTask() {
@@ -150,7 +155,7 @@ export default {
});
this.$emit('onCreate', data.workItemCreateFromTask.workItem.descriptionHtml);
} catch {
- this.error = this.$options.createErrorText;
+ this.error = this.createErrorText;
}
},
handleTitleInput(title) {
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 e9840889bdb..a2cacd8bd7a 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -3,9 +3,13 @@ import { GlAlert } from '@gitlab/ui';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { visitUrl } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
import ZenMode from '~/zen_mode';
import WorkItemDetail from '../components/work_item_detail.vue';
+import {
+ sprintfWorkItem,
+ I18N_WORK_ITEM_ERROR_DELETING,
+ I18N_WORK_ITEM_DELETED,
+} from '../constants';
import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
export default {
@@ -34,7 +38,7 @@ export default {
this.ZenMode = new ZenMode();
},
methods: {
- deleteWorkItem() {
+ deleteWorkItem(workItemType) {
this.$apollo
.mutate({
mutation: deleteWorkItemMutation,
@@ -53,13 +57,12 @@ export default {
throw new Error(workItemDelete.errors[0]);
}
- this.$toast.show(s__('WorkItem|Work item deleted'));
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_DELETED, workItemType);
+ this.$toast.show(msg);
visitUrl(this.issuesListPath);
})
.catch((e) => {
- this.error =
- e.message ||
- s__('WorkItem|Something went wrong when deleting the work item. Please try again.');
+ this.error = e.message || sprintfWorkItem(I18N_WORK_ITEM_ERROR_DELETING, workItemType);
});
},
},
@@ -69,6 +72,6 @@ export default {
<template>
<div>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert>
- <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem" />
+ <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem($event)" />
</div>
</template>
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 004dc22c9b8..9e81e1d4771 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -4,7 +4,6 @@
@import './pages/commits';
@import './pages/deploy_keys';
@import './pages/detail_page';
-@import './pages/editor';
@import './pages/environment_logs';
@import './pages/events';
@import './pages/groups';
@@ -21,7 +20,6 @@
@import './pages/notifications';
@import './pages/pipelines';
@import './pages/profile';
-@import './pages/profiles/preferences';
@import './pages/projects';
@import './pages/prometheus';
@import './pages/registry';
diff --git a/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss
index f6be241d644..324c23022ca 100644
--- a/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss
+++ b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss
@@ -7,14 +7,14 @@
@return $string;
}
-@mixin dropzone-background($stroke-color, $stroke-width: 4, $stroke-linecap: 'butt') {
- background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='#{encodecolor($stroke-color)}' stroke-width='#{$stroke-width}' stroke-dasharray='6%2c4' stroke-dashoffset='0' stroke-linecap='#{encodecolor($stroke-linecap)}'/%3e%3c/svg%3e");
+@mixin dropzone-background($stroke-color, $stroke-width: 4) {
+ background-image: url("data:image/svg+xml, %3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='#{$border-radius-default}' ry='#{$border-radius-default}' stroke='#{encodecolor($stroke-color)}' stroke-width='#{$stroke-width}' stroke-dasharray='6%2c4' stroke-dashoffset='0' stroke-linecap='butt' /%3e %3c/svg%3e");
}
.upload-dropzone-border {
border: 0;
- @include dropzone-background($gray-400, 2, 'round');
- border-radius: 8px;
+ @include dropzone-background($gray-400, 2);
+ border-radius: $border-radius-default;
}
.upload-dropzone-card {
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index ad0036df607..34c7ffa58fe 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -149,7 +149,7 @@
margin: $sidebar-top-item-tb-margin $sidebar-top-item-lr-margin;
&:hover {
- background-color: $indigo-900-alpha-008;
+ background-color: $nav-active-bg;
}
}
@@ -275,7 +275,7 @@
&:not(.fly-out-top-item) {
> a:not(.has-sub-items) {
- background-color: $indigo-900-alpha-008;
+ background-color: $nav-active-bg;
}
}
}
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index 904d041fdc9..8d1fb5eb55f 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -34,19 +34,28 @@
@media (min-width: map-get($grid-breakpoints, md)) {
// The `+11` is to ensure the file header border shows when scrolled -
// the bottom of the compare-versions header and the top of the file header
- $mr-file-header-top: calc(#{$header-height} + #{$mr-tabs-height});
+ --initial-top: calc(#{$header-height} + #{$mr-tabs-height});
+ --top: var(--initial-top);
position: -webkit-sticky;
position: sticky;
- top: $mr-file-header-top;
+ top: var(--top);
z-index: 120;
+ &.is-sidebar-moved {
+ --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 28px});
+ }
+
.with-system-header & {
- top: calc(#{$mr-file-header-top} + #{$system-header-height});
+ --top: calc(var(--initial-top) + #{$system-header-height});
}
.with-system-header.with-performance-bar & {
- top: calc(#{$mr-file-header-top} + #{$system-header-height} + #{$performance-bar-height});
+ --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height});
+ }
+
+ .with-performance-bar & {
+ top: calc(var(--initial-top) + #{$performance-bar-height});
}
&::before {
@@ -60,10 +69,6 @@
pointer-events: none;
}
- .with-performance-bar & {
- top: calc(#{$mr-file-header-top} + #{$performance-bar-height});
- }
-
&.is-commit {
top: calc(#{$header-height} + #{$commit-stat-summary-height});
@@ -788,11 +793,13 @@ table.code {
}
.diff-comments-more-count,
-.diff-notes-collapse {
+.diff-notes-collapse,
+.diff-codequality-collapse {
@include avatar-counter(50%);
}
-.diff-notes-collapse {
+.diff-notes-collapse,
+.diff-codequality-collapse {
border: 0;
border-radius: 50%;
padding: 0;
@@ -977,7 +984,8 @@ table.code {
position: relative;
}
- .diff-notes-collapse {
+ .diff-notes-collapse,
+ .diff-codequality-collapse {
position: absolute;
left: -12px;
}
@@ -1107,6 +1115,7 @@ table.code {
}
.diff-notes-collapse,
+ .diff-codequality-collapse,
.note,
.discussion-reply-holder {
display: none;
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index b980d7fdaa7..07516275e58 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -39,8 +39,8 @@
.file-title {
position: relative;
- background-color: $gray-light;
- border-bottom: 1px solid $border-color;
+ background-color: var(--gray-10, $gray-10);
+ border-bottom: 1px solid var(--border-color, $border-color);
margin: 0;
text-align: left;
padding: 10px $gl-padding;
@@ -471,11 +471,6 @@ span.idiff {
}
}
-.jupyter-notebook-scrolled {
- overflow-y: auto;
- max-height: 20rem;
-}
-
#js-openapi-viewer {
pre.version,
code {
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 37f92d3cf3d..ed41d10f3b2 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -48,7 +48,7 @@
opacity: 0;
}
- a {
+ a:not(.canary-badge) {
display: flex;
align-items: center;
padding: 2px 8px;
@@ -61,10 +61,6 @@
}
}
- .canary-badge {
- margin-left: -8px;
- }
-
.project-item-select {
right: auto;
left: 0;
@@ -564,15 +560,11 @@
}
.frequent-items-list-item-container > a:hover {
- background-color: $nav-active-bg;
+ background-color: $nav-active-bg !important;
}
}
.top-nav-toggle {
- .dropdown-icon {
- @include gl-mr-3;
- }
-
.dropdown-chevron {
top: 0;
}
@@ -581,7 +573,7 @@
.top-nav-menu-item {
&.active,
&:hover {
- background-color: $nav-active-bg;
+ background-color: $nav-active-bg !important;
}
.gl-icon {
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index ab426f388c6..a63ce66e681 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -31,8 +31,7 @@
width: 100%;
padding-left: 10px;
padding-right: 10px;
- white-space: break-spaces;
- word-break: break-word;
+ white-space: pre;
&:empty::before {
content: '\200b';
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index fb05f8575ef..02b76b89482 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -7,9 +7,6 @@ html {
}
body {
- // Improves readability for dyslexic users; supported only in Chrome/Safari so far
- text-decoration-skip: ink;
-
&.navless {
background-color: $white !important;
}
@@ -139,6 +136,13 @@ body {
}
}
+.gl--flex-full {
+ @include gl-display-flex;
+ @include gl-align-items-stretch;
+ @include gl-overflow-hidden;
+}
+
+
.with-performance-bar .layout-page {
margin-top: calc(#{$header-height} + #{$performance-bar-height});
}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 7522f791b8e..b623f18c4ae 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -104,7 +104,6 @@
li.md-header-toolbar {
margin-left: auto;
visibility: hidden;
- padding-bottom: $gl-padding-8;
&.active {
visibility: visible;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 92ca8654287..7e0a601223d 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -13,9 +13,9 @@
a,
button {
- padding: $gl-padding-8;
- font-size: 14px;
- line-height: 28px;
+ padding: $gl-spacing-scale-5 $gl-spacing-scale-4;
+ font-size: $gl-font-size;
+ line-height: $gl-line-height-16;
color: $gl-text-color-secondary;
border: 0;
white-space: nowrap;
@@ -88,10 +88,6 @@
float: left;
}
- li a {
- padding: 16px 15px 11px;
- }
-
/* Small devices (phones, 768px and lower) */
@include media-breakpoint-down(sm) {
width: 100%;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index ae0f18753ad..7878e08e549 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -208,7 +208,7 @@
position: relative;
top: -3px;
padding: $gl-padding-4 0;
- background-color: $gray-light;
+ background-color: $body-bg;
&.opened {
color: $green-500;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 031f5dc45ca..e79fb843967 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -333,7 +333,7 @@
font-size: 13px;
line-height: 1.6em;
overflow-x: auto;
- border-radius: 2px;
+ border-radius: $border-radius-default;
// Multi-line code blocks should scroll horizontally
code {
@@ -427,10 +427,11 @@
padding-inline-start: 28px;
margin-inline-start: 0 !important;
+ > p > input.task-list-item-checkbox,
> input.task-list-item-checkbox {
position: absolute;
- inset-inline-start: 8px;
- top: 5px;
+ inset-inline-start: $gl-padding-8;
+ inset-block-start: 5px;
}
}
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index e9ad930ef2b..bd32a817d5d 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -364,7 +364,7 @@ $well-expand-item: #e8f2f7 !default;
$well-inner-border: #eef0f2 !default;
$well-light-border: #f1f1f1;
$well-light-text-color: #5b6169;
-$nav-active-bg: var(--nav-active-bg, rgba($black, 0.08)) !important;
+$nav-active-bg: rgba($black, 0.08);
/*
* Text
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index 197073412e8..d45bc865da5 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -5,34 +5,6 @@
pointer-events: none;
}
-.dropdown-projects {
- .dropdown-content {
- max-height: 200px;
- }
-}
-
-.issue-board-dropdown-content {
- margin: 0;
- padding: $gl-padding-4 $gl-padding $gl-padding;
- border-bottom: 0;
- color: var(--gray-500, $gray-500);
-}
-
-[data-page$='epic_boards:index'],
-[data-page$='epic_boards:show'],
-.issue-boards-page {
- .content-wrapper {
- padding-bottom: 0;
- }
-}
-
-[data-page$='epic_boards:index'],
-[data-page$='epic_boards:show'] {
- .filtered-search-wrapper {
- display: none !important;
- }
-}
-
.boards-app {
@include media-breakpoint-up(sm) {
transition: width $gl-transition-duration-medium;
@@ -87,33 +59,7 @@
width: 400px;
}
- .board-title-caret {
- border-radius: $border-radius-default;
- line-height: $gl-spacing-scale-5;
-
- &.btn svg {
- top: 0;
- }
-
- &:hover {
- background-color: var(--gray-50, $gray-50);
- transition: background-color 0.1s linear;
- }
- }
-
- &:not(.is-collapsed) {
- .board-title-caret {
- margin-right: $gl-padding-4;
- }
- }
-
&.is-collapsed {
- width: 50px;
-
- .board-title-caret {
- margin-top: 1px;
- }
-
.board-title-text > span,
.issue-count-badge > span {
height: 16px;
@@ -124,17 +70,11 @@
// rotated element has square dimensions so it won't overlap with its siblings.
margin: calc(50% - 8px) 0;
- transform: rotate(90deg);
transform-origin: center;
}
}
}
-.board-inner {
- font-size: $issue-boards-font-size;
- background: var(--gray-50, $gray-50);
-}
-
// to highlight columns we have animated pulse of box-shadow
// we don't want to actually animate the box-shadow property
// because that causes costly repaints. Instead we can add a
@@ -169,103 +109,45 @@
}
}
-.board-title {
- height: 3rem;
-
- .max-issue-size::before {
- content: '/';
- }
-}
-
-.board-list-component {
- min-height: 0; // firefox fix
-}
-
-.board-list {
- overflow-y: auto;
- overflow-x: hidden;
-}
-
-.board-list-loading {
- margin-top: 10px;
- font-size: (26px / $issue-boards-font-size) * 1em;
-}
-
.board-card {
background: var(--gray-10, $white);
box-shadow: 0 1px 2px rgba(var(--black, $black), 0.1);
- line-height: $gl-padding;
- list-style: none;
- position: relative;
-
- &:not(:last-child) {
- margin-bottom: $gl-padding-8;
- }
-
- &.is-active,
- &.is-active .board-card-assignee:hover a {
- background-color: var(--blue-50, $blue-50);
- }
-
- &.multi-select {
- border-color: var(--blue-200, $blue-200);
- background-color: var(--blue-50, $blue-50);
- }
-
- &.sortable-chosen {
- box-shadow: 0 2px 4px 0 rgba($black, 0.16);
- }
- .gl-label {
- margin-top: 4px;
- margin-right: 4px;
+ &:last-child {
+ @include gl-mb-0;
}
- .confidential-icon,
- .hidden-icon {
- color: var(--orange-500, $orange-500);
- cursor: help;
+ .move-to-position {
+ visibility: hidden;
}
- .issue-blocked-icon {
- color: var(--red-500, $red-500);
+ &:hover .move-to-position {
+ visibility: visible;
}
- @include media-breakpoint-down(md) {
- padding: $gl-padding-8;
+ @include media-breakpoint-down(sm) {
+ .move-to-position {
+ visibility: visible;
+ }
}
}
.board-card-title {
- @include overflow-break-word();
- font-size: 1em;
+ width: 95%;
a {
- color: var(--gray-900, $gray-900);
- }
-
- @include media-breakpoint-down(md) {
- font-size: $label-font-size;
+ @include media-breakpoint-down(md) {
+ font-size: $gl-font-size-sm;
+ }
}
}
.board-card-assignee {
- margin-top: -$gl-padding-4;
- margin-bottom: -$gl-padding-4;
-
.avatar-counter {
- vertical-align: middle;
- line-height: $gl-padding-24;
min-width: $gl-padding-24;
height: $gl-padding-24;
border-radius: $gl-padding-24;
- background-color: var(--gray-400, $gray-400);
font-size: $gl-font-size-xs;
- cursor: help;
- font-weight: $gl-font-weight-bold;
- margin-left: -$gl-padding-4;
- border: 0;
- padding: 0 $gl-padding-4;
@include media-breakpoint-down(md) {
min-width: auto;
@@ -275,12 +157,8 @@
}
}
- img {
- vertical-align: top;
- }
-
.user-avatar-link:not(:only-child) {
- margin-left: -$gl-padding-4;
+ margin-left: -$gl-padding;
&:nth-of-type(1) {
z-index: 2;
@@ -299,89 +177,26 @@
}
@include media-breakpoint-down(md) {
- margin-top: 0;
- margin-bottom: 0;
+ margin-bottom: 0 !important;
}
}
.board-card-number {
- font-size: $gl-font-size-xs;
- color: var(--gray-500, $gray-500);
-
- @include media-breakpoint-up(md) {
- font-size: $label-font-size;
+ @include media-breakpoint-down(md) {
+ font-size: $gl-font-size-sm;
}
}
.board-list-count {
- padding: 10px 0;
- color: var(--gray-500, $gray-500);
font-size: 13px;
}
-.board-new-issue-form {
- z-index: 4;
- margin: 5px;
-}
-
-.right-sidebar.boards-sidebar {
- .gutter-toggle {
- bottom: 15px;
- width: 22px;
- padding-left: $gl-padding-32;
-
- svg {
- position: absolute;
- top: 50%;
- right: 0;
- margin-top: (-11px / 2);
- height: $gl-font-size-12;
- width: $gl-font-size-12;
- }
- }
-
- .issuable-header-text {
- @include overflow-break-word();
- padding-right: 35px;
- }
-}
-
-.right-sidebar.right-sidebar-expanded {
- &.boards-sidebar-slide-enter-active,
- &.boards-sidebar-slide-leave-active {
- transition: width $gl-transition-duration-medium, padding $gl-transition-duration-medium;
- }
-
- &.boards-sidebar-slide-enter,
- &.boards-sidebar-slide-leave-active {
- width: 0;
- padding-left: 0;
- padding-right: 0;
- }
-}
-
.board-card-info {
- color: var(--gray-500, $gray-500);
- white-space: nowrap;
- margin-right: $gl-padding-8;
-
- &:not(.board-card-weight) {
- cursor: help;
- }
-
- &.board-card-weight {
- color: var(--gray-500, $gray-500);
- cursor: pointer;
-
- &:hover {
- color: initial;
- text-decoration: underline;
- }
+ &.board-card-weight:hover {
+ color: initial;
}
.board-card-info-icon {
- color: var(--gray-500, $gray-500);
- margin-right: $gl-padding-4;
vertical-align: text-top;
}
@@ -394,15 +209,6 @@
cursor: help;
}
-.board-labels-toggle-wrapper,
-.board-swimlanes-toggle-wrapper {
- /**
- * Make the wrapper the same height as a button so it aligns properly when the
- * filtered-search-box input element increases in size on Linux smaller breakpoints
- */
- height: $input-height;
-}
-
.issue-boards-content:not(.breadcrumbs) {
isolation: isolate;
}
@@ -422,7 +228,6 @@
.boards-list {
height: calc(100vh - #{$issue-boards-filter-height});
- overflow-x: scroll;
}
.boards-sidebar {
@@ -433,15 +238,7 @@
.boards-sidebar {
.sidebar-collapsed-icon {
- display: none;
- }
-
- .gl-drawer-header {
- align-items: flex-start;
- }
-
- .labels-select-wrapper.is-embedded .labels-select-wrapper.is-embedded {
- width: auto;
+ @include gl-display-none;
}
.show.dropdown .dropdown-menu {
@@ -449,10 +246,6 @@
}
}
-.board-header-collapsed-info-icon:hover {
- color: var(--gray-900, $gray-900);
-}
-
.board-card-skeleton {
height: 110px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/page_bundles/editor.scss
index c177d0b74a2..b7b698b2128 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/page_bundles/editor.scss
@@ -1,11 +1,13 @@
+@import 'page_bundles/mixins_and_variables_and_functions';
+
.file-editor {
.nav-links {
- border-top: 1px solid $border-color;
- border-right: 1px solid $border-color;
- border-left: 1px solid $border-color;
+ border-top: 1px solid var(--border-color, $border-color);
+ border-right: 1px solid var(--border-color, $border-color);
+ border-left: 1px solid var(--border-color, $border-color);
border-bottom: 0;
border-radius: $border-radius-small $border-radius-small 0 0;
- background: $gray-normal;
+ background: var(--gray-50, $gray-50);
}
#editor,
@@ -23,10 +25,6 @@
}
}
- .ace_gutter-cell {
- background-color: $gray-light;
- }
-
.cancel-btn {
color: $red-600;
@@ -40,9 +38,9 @@
}
.editor-ref {
- background: $gray-light;
+ background: var(--gray-10, $gray-10);
padding-right: $gl-padding;
- border-right: 1px solid $border-color;
+ border-right: 1px solid var(--border-color, $border-color);
display: block;
float: left;
margin-right: 10px;
diff --git a/app/assets/stylesheets/page_bundles/group.scss b/app/assets/stylesheets/page_bundles/group.scss
index 71dbb855103..5086cdbf9bc 100644
--- a/app/assets/stylesheets/page_bundles/group.scss
+++ b/app/assets/stylesheets/page_bundles/group.scss
@@ -1,35 +1,16 @@
@import 'page_bundles/mixins_and_variables_and_functions';
.group-home-panel {
- margin-top: $gl-padding;
- margin-bottom: $gl-padding;
-
.home-panel-avatar {
width: $home-panel-title-row-height;
height: $home-panel-title-row-height;
- flex-shrink: 0;
flex-basis: $home-panel-title-row-height;
}
.home-panel-title {
- font-size: 20px;
- line-height: $gl-line-height-24;
- font-weight: bold;
-
.icon {
vertical-align: -1px;
}
-
- .home-panel-topic-list {
- font-size: $gl-font-size;
- font-weight: $gl-font-weight-normal;
-
- .icon {
- position: relative;
- top: 3px;
- margin-right: $gl-padding-4;
- }
- }
}
.home-panel-title-row {
@@ -52,7 +33,7 @@
line-height: $gl-font-size-large;
}
- .home-panel-topic-list,
+
.home-panel-metadata {
font-size: $gl-font-size-small;
}
@@ -60,8 +41,6 @@
}
.home-panel-metadata {
- font-weight: normal;
- font-size: 14px;
line-height: $gl-btn-line-height;
}
diff --git a/app/assets/stylesheets/page_bundles/issues_show.scss b/app/assets/stylesheets/page_bundles/issues_show.scss
index c664e0a734e..26d694f7421 100644
--- a/app/assets/stylesheets/page_bundles/issues_show.scss
+++ b/app/assets/stylesheets/page_bundles/issues_show.scss
@@ -1,77 +1,24 @@
@import 'mixins_and_variables_and_functions';
.description {
- ul,
- ol {
- /* We're changing list-style-position to inside because the default of
- * outside doesn't move negative margin to the left of the bullet. */
- list-style-position: inside;
- }
-
li {
position: relative;
- /* In the browser, the li element comes after (to the right of) the bullet point, so hovering
- * over the left of the bullet point doesn't trigger a row hover. To trigger hovering on the
- * left, we're applying negative margin here to shift the li element left. */
- margin-inline-start: -1rem;
- padding-inline-start: 2.5rem;
+ margin-inline-start: 2.25rem;
.drag-icon {
position: absolute;
inset-block-start: 0.3rem;
- inset-inline-start: 1rem;
- }
-
- /* The inside bullet aligns itself to the bottom, which we see when text to the right of
- * a multi-line list item wraps. We fix this by aligning it to the top, and excluding
- * other elements. Targeting ::marker doesn't seem to work, instead we exclude custom elements
- * or anything with a class */
- > *:not(gl-emoji, code, [class]) {
- vertical-align: top;
- }
-
- /* The inside bullet is treated like an element inside the li element, so when we have a
- * multi-paragraph list item, the text doesn't start on the right of the bullet because
- * it is a block level p element. We make it inline to fix this. */
- > p:first-of-type {
- display: inline-block;
- max-width: calc(100% - 1.5rem);
- }
-
- /* We fix the other paragraphs not indenting to the
- * right of the bullet due to the inside bullet. */
- p ~ a,
- p ~ blockquote,
- p ~ code,
- p ~ details,
- p ~ dl,
- p ~ h1,
- p ~ h2,
- p ~ h3,
- p ~ h4,
- p ~ h5,
- p ~ h6,
- p ~ hr,
- p ~ ol,
- p ~ p,
- p ~ table:not(.code), /* We need :not(.code) to override typography.scss */
- p ~ ul,
- p ~ .markdown-code-block {
- margin-inline-start: 1rem;
+ inset-inline-start: -2.3rem;
+ padding-inline-end: 1rem;
+ width: 2rem;
}
}
- ul.task-list {
- > li.task-list-item {
- /* We're using !important to override the same selector in typography.scss */
- margin-inline-start: -1rem !important;
- padding-inline-start: 2.5rem;
+ ul.task-list > li.task-list-item {
+ margin-inline-start: 0.5rem !important; /* Override typography.scss */
- > input.task-list-item-checkbox {
- position: static;
- vertical-align: middle;
- margin-block-start: -2px;
- }
+ > .drag-icon {
+ inset-inline-start: -0.6rem;
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index b7a75884425..463c8f74342 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -41,17 +41,21 @@ $tabs-holder-z-index: 250;
// If they don't match, the file tree and the diff files stick
// to the top at different heights, which is a bad-looking defect
$diff-file-header-top: 11px;
- $top-pos: calc(#{$header-height} + #{$mr-tabs-height} + #{$diff-file-header-top});
+ --initial-pos: calc(#{$header-height} + #{$mr-tabs-height} + #{$diff-file-header-top});
+ --top-pos: var(--initial-pos);
position: -webkit-sticky;
position: sticky;
- // Unitless zero values are not allowed in calculations
- top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px));
- max-height: calc(100vh - #{$top-pos} - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
+ top: var(--top-pos);
+ max-height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
.drag-handle {
bottom: 16px;
}
+
+ &.is-sidebar-moved {
+ --top-pos: calc(var(--initial-pos) + 26px);
+ }
}
.tree-list-holder {
@@ -196,12 +200,8 @@ $tabs-holder-z-index: 250;
background-color: var(--gray-50, $gray-50);
}
-.mr-conflict-loader {
- max-width: 334px;
-
- > svg {
- vertical-align: middle;
- }
+.mr-widget-body-loading svg {
+ vertical-align: middle;
}
.mr-info-list {
@@ -398,12 +398,6 @@ $tabs-holder-z-index: 250;
display: block;
}
- .mr-widget-pipeline-graph {
- .dropdown-menu {
- z-index: $zindex-dropdown-menu;
- }
- }
-
.normal {
flex: 1;
flex-basis: auto;
@@ -440,7 +434,7 @@ $tabs-holder-z-index: 250;
.mr-widget-body {
&:not(.mr-widget-body-line-height-1) {
- line-height: 28px;
+ line-height: 24px;
}
@include clearfix;
@@ -475,12 +469,6 @@ $tabs-holder-z-index: 250;
margin: 0 0 0 10px;
}
- .bold {
- font-weight: $gl-font-weight-bold;
- color: var(--gray-600, $gray-600);
- margin-left: 10px;
- }
-
.state-label {
font-weight: $gl-font-weight-bold;
padding-right: 10px;
@@ -490,11 +478,6 @@ $tabs-holder-z-index: 250;
color: var(--red-500, $red-500);
}
- .spacing,
- .bold {
- vertical-align: middle;
- }
-
.dropdown-menu {
li a {
padding: 5px;
@@ -621,8 +604,8 @@ $tabs-holder-z-index: 250;
.mr-widget-extension-icon::before {
@include gl-content-empty;
@include gl-absolute;
- @include gl-left-0;
- @include gl-top-0;
+ @include gl-left-50p;
+ @include gl-top-half;
@include gl-opacity-3;
@include gl-border-solid;
@include gl-border-4;
@@ -630,24 +613,20 @@ $tabs-holder-z-index: 250;
width: 24px;
height: 24px;
+ transform: translate(-50%, -50%);
}
.mr-widget-extension-icon::after {
@include gl-content-empty;
@include gl-absolute;
@include gl-rounded-full;
+ @include gl-left-50p;
+ @include gl-top-half;
- top: 4px;
- left: 4px;
width: 16px;
height: 16px;
- border: 4px solid currentColor;
-}
-
-.mr-widget-extension-icon svg {
- position: relative;
- top: 2px;
- left: 2px;
+ border: 4px solid;
+ transform: translate(-50%, -50%);
}
.mr-widget-heading {
@@ -777,6 +756,7 @@ $tabs-holder-z-index: 250;
&.show .dropdown-menu {
width: calc(100vw - 20px);
max-width: 650px;
+ max-height: calc(100vh - 50px);
.gl-new-dropdown-inner {
max-height: none !important;
@@ -818,8 +798,7 @@ $tabs-holder-z-index: 250;
}
.md-preview-holder {
- max-height: 180px;
- height: 180px;
+ max-height: 172px;
}
}
@@ -840,3 +819,29 @@ $tabs-holder-z-index: 250;
}
}
}
+
+.merge-request-sticky-header {
+ z-index: 204;
+ box-shadow: 0 1px 2px $issue-boards-card-shadow;
+ --width: calc(100% - #{$contextual-sidebar-width});
+
+ @include media-breakpoint-down(lg) {
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width});
+ }
+}
+
+.detail-page-header-actions {
+ .gl-toggle {
+ @include gl-ml-auto;
+ }
+}
+
+.page-with-icon-sidebar .issue-sticky-header {
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width});
+}
+
+.merge-request-notification-toggle {
+ .gl-toggle-label {
+ @include gl-font-weight-normal;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/pipeline_schedules.scss b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss
index 412971253ca..0c73bece035 100644
--- a/app/assets/stylesheets/page_bundles/pipeline_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss
@@ -28,13 +28,6 @@
.pipeline-schedule-table-row {
.branch-name-cell {
max-width: 300px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .next-run-cell {
- color: var(--gray-500, $gray-500);
}
a {
@@ -50,7 +43,6 @@
.bordered-box.content-block {
border: 1px solid var(--border-color, $border-color);
background-color: transparent;
- padding: $gl-spacing-scale-5;
}
}
diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/page_bundles/profiles/preferences.scss
index c7d7aacceec..c9c78a70163 100644
--- a/app/assets/stylesheets/pages/profiles/preferences.scss
+++ b/app/assets/stylesheets/page_bundles/profiles/preferences.scss
@@ -1,3 +1,5 @@
+@import 'page_bundles/mixins_and_variables_and_functions';
+
.application-theme {
$ui-gray-bg: #303030;
$ui-light-gray-bg: #f0f0f0;
diff --git a/app/assets/stylesheets/page_bundles/reports.scss b/app/assets/stylesheets/page_bundles/reports.scss
index d0748779f47..03c9fc7508d 100644
--- a/app/assets/stylesheets/page_bundles/reports.scss
+++ b/app/assets/stylesheets/page_bundles/reports.scss
@@ -16,6 +16,10 @@
line-height: 20px;
}
+.report-block-child-icon {
+ height: 20px;
+}
+
.report-block-list {
list-style: none;
padding: 0 1px;
diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss
index e7813e3b56e..3eacf98688e 100644
--- a/app/assets/stylesheets/page_bundles/todos.scss
+++ b/app/assets/stylesheets/page_bundles/todos.scss
@@ -9,12 +9,6 @@
// workaround because we cannot use border-collapse
border-top: 1px solid transparent;
- &:hover {
- background-color: var(--blue-50, $blue-50);
- border-color: var(--blue-200, $blue-200);
- cursor: pointer;
- }
-
// overwrite border style of .content-list
&:last-child {
border-bottom: 1px solid transparent;
@@ -26,8 +20,6 @@
&.todo-pending.done-reversible {
&:hover {
- border-color: var(--border-color, $border-color);
- background-color: var(--gray-50, $gray-50);
border-top: 1px solid transparent;
.todo-avatar,
@@ -40,20 +32,12 @@
.todo-item {
opacity: 0.2;
}
-
- .btn {
- background-color: var(--gray-50, $gray-50);
- }
}
}
.todo-item {
@include transition(opacity);
- .status-box {
- line-height: inherit;
- }
-
.todo-label,
.todo-project {
a {
@@ -66,22 +50,6 @@
color: var(--gl-text-color, $gl-text-color);
}
- pre {
- border: 0;
- background: var(--gray-50, $gray-50);
- border-radius: 0;
- color: var(--gray-500, $gray-500);
- margin: 0 20px;
- overflow: hidden;
- }
-
- .note-image-attach {
- margin-top: 4px;
- margin-left: 0;
- max-width: 200px;
- float: none;
- }
-
.gl-label-scoped {
--label-inset-border: inset 0 0 0 1px currentColor;
}
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
index 9220fa82b46..d0fc011dde7 100644
--- a/app/assets/stylesheets/page_bundles/work_items.scss
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -7,7 +7,7 @@
#weight-widget-input:not(:hover, :focus),
#weight-widget-input[readonly] {
- box-shadow: inset 0 0 0 $gl-border-size-1 var(--white, $white);
+ box-shadow: none;
}
#weight-widget-input[readonly] {
@@ -19,8 +19,38 @@
display: none;
}
- .assignees-selector:hover .assign-myself {
- display: block;
+ @include media-breakpoint-up(sm) {
+ .assignees-selector:hover .assign-myself {
+ display: block;
+ }
+ }
+}
+
+.work-item-due-date {
+ .gl-datepicker-input.gl-form-input.form-control {
+ width: 10rem;
+
+ &:not(:focus, :hover) {
+ box-shadow: none;
+
+ ~ .gl-datepicker-actions {
+ display: none;
+ }
+ }
+
+ &[disabled] {
+ background-color: var(--white, $white);
+ box-shadow: none;
+
+ ~ .gl-datepicker-actions {
+ display: none;
+ }
+ }
+ }
+
+ .gl-datepicker-actions:focus,
+ .gl-datepicker-actions:hover {
+ display: flex !important;
}
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index c96d8ecc782..19318d87731 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -33,12 +33,6 @@
height: 22px;
}
}
-
- .mr-widget-pipeline-graph {
- .dropdown-menu {
- margin-top: 11px;
- }
- }
}
.branch-info .commit-icon {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 69797c6b303..85205f4d5ac 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -297,7 +297,7 @@
padding: 0;
.issuable-context-form {
- --initial-top: calc(#{$header-height} + #{$mr-tabs-height});
+ --initial-top: calc(#{$header-height} + 76px);
--top: var(--initial-top);
@include gl-sticky;
@@ -613,7 +613,7 @@
}
.participants-author {
- &:nth-of-type(7n) {
+ &:nth-of-type(8n) {
padding-right: 0;
}
@@ -962,40 +962,26 @@
border-left: 2px solid $gray-50;
position: absolute;
left: 39px;
- height: 80%;
+ height: calc(100% + #{$gl-spacing-scale-5});
+ top: -#{$gl-spacing-scale-5};
}
- &:first-child::before,
- &:last-child::after {
+ &:first-child::before {
content: none;
}
&:first-child {
&::after {
- top: 50%;
+ top: $gl-spacing-scale-5;
+ height: calc(100% + #{$gl-spacing-scale-5});
}
}
- &:last-child {
+ &:last-child,
+ &.create-timeline-event {
&::before {
- bottom: 50%;
- }
- }
-
- &:not(:first-child):not(:last-child) {
- &::before {
- top: -10%;
- }
-
- &::after {
- bottom: -10%;
- }
- }
-
- &.timeline-event-note-form {
- &::before {
- top: -15% !important; // Override default positioning
- height: 20%;
+ top: - #{$gl-spacing-scale-5} !important; // Override default positioning
+ @include gl-h-8;
}
&::after {
@@ -1007,3 +993,22 @@
.timeline-event-note-form {
padding-left: 20px;
}
+
+.timeline-entry:not(:last-child) {
+ .timeline-event-border {
+ @include gl-pb-5;
+ @include gl-border-gray-50;
+ @include gl-border-1;
+ @include gl-border-b-solid;
+ }
+}
+
+.timeline-group:last-child {
+ .timeline-entry:last-child,
+ .create-timeline-event {
+ .timeline-event-bottom-border {
+ @include gl-border-b;
+ @include gl-pt-5;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index a151c28fe93..843daec8cda 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -259,13 +259,15 @@ ul.related-merge-requests > li gl-emoji {
}
.issue-sticky-header {
+ --width: 100%;
+
@include gl-left-0;
- @include gl-w-full;
+ width: var(--width);
top: $header-height;
// collapsed right sidebar
@include media-breakpoint-up(sm) {
- width: calc(100% - #{$gutter-collapsed-width});
+ --width: calc(100% - #{$gutter-collapsed-width});
}
}
@@ -283,12 +285,12 @@ ul.related-merge-requests > li gl-emoji {
// collapsed left sidebar + collapsed right sidebar
.issue-sticky-header {
left: $contextual-sidebar-collapsed-width;
- width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
}
// collapsed left sidebar + expanded right sidebar
.right-sidebar-expanded .issue-sticky-header {
- width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
}
}
@@ -296,23 +298,23 @@ ul.related-merge-requests > li gl-emoji {
// expanded left sidebar + collapsed right sidebar
.issue-sticky-header {
left: $contextual-sidebar-width;
- width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-collapsed-width});
+ --width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-collapsed-width});
}
// collapsed left sidebar + collapsed right sidebar
.page-with-icon-sidebar .issue-sticky-header {
left: $contextual-sidebar-collapsed-width;
- width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
}
// expanded left sidebar + expanded right sidebar
.right-sidebar-expanded .issue-sticky-header {
- width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-width});
+ --width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-width});
}
// collapsed left sidebar + expanded right sidebar
.right-sidebar-expanded.page-with-icon-sidebar .issue-sticky-header {
- width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
}
}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 1beb9f05b6c..d4ad6da7f4d 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -1,3 +1,4 @@
+@import 'framework/variables';
/* Login Page */
.login-page {
.container {
@@ -41,6 +42,13 @@
font-size: 13px;
}
+ .signin-text {
+ p {
+ margin-bottom: 0;
+ line-height: 1.5;
+ }
+ }
+
.borderless {
.login-box,
.omniauth-container {
@@ -118,6 +126,18 @@
}
.new-session-tabs {
+ &.nav-links-unboxed {
+ border-color: transparent;
+ box-shadow: none;
+
+ .nav-item {
+ border-left: 0;
+ border-right: 0;
+ border-bottom: 1px solid $gray-100;
+ background-color: transparent;
+ }
+ }
+
display: flex;
box-shadow: 0 0 0 1px $border-color;
border-top-right-radius: $border-radius-default;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 9692becef4f..cb77c31d59a 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -48,7 +48,7 @@
.common-note-form {
.md-area {
- padding: $gl-padding-8 $gl-padding;
+ padding: 0 $gl-padding;
border: 1px solid $border-color;
border-radius: $border-radius-base;
transition: border-color ease-in-out 0.15s,
@@ -305,7 +305,6 @@ table {
}
.comment-toolbar {
- padding-top: $gl-padding-top;
color: $gl-text-color-secondary;
border-top: 1px solid $border-color;
}
@@ -336,8 +335,7 @@ table {
.toolbar-text {
font-size: 14px;
- line-height: 16px;
- margin-top: 2px;
+ line-height: $gl-spacing-scale-7;
@include media-breakpoint-up(md) {
float: left;
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index db07f16dfd0..fc1b78bf730 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -164,7 +164,7 @@ $system-note-svg-size: 16px;
}
.note-body {
- padding: $gl-padding-4;
+ padding: $gl-padding-4 $gl-padding-4 $gl-padding-4 $gl-padding-8;
overflow-x: auto;
overflow-y: hidden;
@@ -305,7 +305,7 @@ $system-note-svg-size: 16px;
height: $system-note-icon-size;
border: 1px solid $gray-10;
border-radius: $system-note-icon-size;
- margin: -6px 20px 0 0;
+ margin: -6px 0 0;
svg {
width: $system-note-svg-size;
@@ -334,10 +334,14 @@ $system-note-svg-size: 16px;
border-radius: 0;
@media (min-width: map-get($grid-breakpoints, md)) {
- top: calc(#{$mr-tabs-height} + #{$header-height});
+ --initial-top: calc(#{$header-height} + #{$mr-tabs-height});
+
+ &.is-sidebar-moved {
+ --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 28px});
+ }
.with-performance-bar & {
- top: 123px;
+ --top: 123px;
}
}
@@ -551,6 +555,7 @@ $system-note-svg-size: 16px;
.note-header {
display: flex;
justify-content: space-between;
+ align-items: center;
flex-wrap: wrap;
> .note-header-info,
@@ -581,7 +586,7 @@ $system-note-svg-size: 16px;
.note-header-info {
min-width: 0;
- padding-left: $gl-padding-4;
+ padding-left: $gl-padding-8;
&.discussion {
padding-bottom: 0;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 6c909b8d9fa..e8f71c8a21c 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -49,7 +49,7 @@ input[type='checkbox']:hover {
}
&.header-search-is-active {
- .navbar-collapse {
+ .global-search-container {
flex-grow: 1;
}
@@ -59,12 +59,6 @@ input[type='checkbox']:hover {
overflow: hidden;
}
}
-
- @include media-breakpoint-up(xl) {
- .navbar-nav {
- padding-left: 1rem;
- }
- }
}
}
@@ -383,6 +377,10 @@ input[type='checkbox']:hover {
.line_holder {
pre {
padding: 0; // This overrides the existing style that will add space between each line.
+ .line {
+ @include gl-word-break-word;
+ white-space: break-spaces;
+ }
}
svg {
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 935595d1b3b..56acf6de828 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -349,3 +349,9 @@
}
}
}
+
+.gl-md-flex-wrap-nowrap.gl-md-flex-wrap-nowrap {
+ @include gl-media-breakpoint-up(md) {
+ @include gl-flex-wrap-nowrap;
+ }
+}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index ffe4d5dde9d..0450b3d9a44 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -10,7 +10,6 @@ body.gl-dark {
--gray-50: #303030;
--gray-100: #404040;
--gray-200: #525252;
- --gray-600: #bfbfbf;
--gray-700: #dbdbdb;
--gray-900: #fafafa;
--green-100: #0d532a;
@@ -18,7 +17,6 @@ body.gl-dark {
--gl-text-color: #fafafa;
--border-color: #4f4f4f;
--black: #fff;
- --nav-active-bg: rgba(255, 255, 255, 0.08);
}
:root {
--white: #333;
@@ -332,9 +330,6 @@ kbd kbd {
color: #fff;
background-color: #c17d10;
}
-.bg-transparent {
- background-color: transparent !important;
-}
.rounded-circle {
border-radius: 50% !important;
}
@@ -459,7 +454,7 @@ a.gl-badge.badge-warning:active {
.gl-form-input:disabled,
.gl-form-input.form-control:disabled {
cursor: not-allowed;
- color: #868686;
+ color: #999;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
@@ -594,9 +589,6 @@ svg {
html {
overflow-y: scroll;
}
-body {
- text-decoration-skip: ink;
-}
.btn {
border-radius: 4px;
font-size: 0.875rem;
@@ -815,20 +807,17 @@ kbd {
.navbar-gitlab .header-content .title img {
height: 24px;
}
-.navbar-gitlab .header-content .title a {
+.navbar-gitlab .header-content .title a:not(.canary-badge) {
display: flex;
align-items: center;
padding: 2px 8px;
margin: 4px 2px 4px -8px;
border-radius: 4px;
}
-.navbar-gitlab .header-content .title a:active {
+.navbar-gitlab .header-content .title a:not(.canary-badge):active {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6), 0 0 0 3px #1068bf;
outline: none;
}
-.navbar-gitlab .header-content .title .canary-badge {
- margin-left: -8px;
-}
.navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) {
margin: 0 2px;
}
@@ -1012,9 +1001,6 @@ kbd {
visibility: hidden;
top: 3px;
}
-.top-nav-toggle .dropdown-icon {
- margin-right: 0.5rem;
-}
.tanuki-logo .tanuki {
fill: #e24329;
}
@@ -1137,7 +1123,7 @@ kbd {
font-weight: 600;
}
.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) {
- background-color: rgba(41, 41, 97, 0.08);
+ background-color: rgba(255, 255, 255, 0.08);
}
.nav-sidebar ul {
padding-left: 0;
@@ -1790,7 +1776,6 @@ body.gl-dark {
--white: #333;
--black: #fff;
--svg-status-bg: #333;
- --nav-active-bg: rgba(255, 255, 255, 0.08);
}
.nav-sidebar,
.toggle-sidebar-button,
@@ -1802,15 +1787,6 @@ body.gl-dark {
.avatar {
background: rgba(255, 255, 255, 0.04);
}
-.nav-sidebar li a {
- color: var(--gray-600);
-}
-.nav-sidebar li.active {
- box-shadow: none;
-}
-.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) {
- background-color: var(--nav-active-bg);
-}
body.gl-dark {
--gl-theme-accent: #868686;
}
@@ -2038,7 +2014,6 @@ body.gl-dark {
--white: #333;
--black: #fff;
--svg-status-bg: #333;
- --nav-active-bg: rgba(255, 255, 255, 0.08);
}
.tab-width-8 {
tab-size: 8;
@@ -2113,6 +2088,12 @@ body.gl-dark {
.gl-pt-0 {
padding-top: 0;
}
+.gl-mr-auto {
+ margin-right: auto;
+}
+.gl-mr-3 {
+ margin-right: 0.5rem;
+}
.gl-ml-n2 {
margin-left: -0.25rem;
}
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 00ca98bfd27..356fb58b4c8 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -311,9 +311,6 @@ kbd kbd {
color: #fff;
background-color: #ab6100;
}
-.bg-transparent {
- background-color: transparent !important;
-}
.rounded-circle {
border-radius: 50% !important;
}
@@ -438,7 +435,7 @@ a.gl-badge.badge-warning:active {
.gl-form-input:disabled,
.gl-form-input.form-control:disabled {
cursor: not-allowed;
- color: #868686;
+ color: #666;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
@@ -573,9 +570,6 @@ svg {
html {
overflow-y: scroll;
}
-body {
- text-decoration-skip: ink;
-}
.btn {
border-radius: 4px;
font-size: 0.875rem;
@@ -794,20 +788,17 @@ kbd {
.navbar-gitlab .header-content .title img {
height: 24px;
}
-.navbar-gitlab .header-content .title a {
+.navbar-gitlab .header-content .title a:not(.canary-badge) {
display: flex;
align-items: center;
padding: 2px 8px;
margin: 4px 2px 4px -8px;
border-radius: 4px;
}
-.navbar-gitlab .header-content .title a:active {
+.navbar-gitlab .header-content .title a:not(.canary-badge):active {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 0 0 3px #63a6e9;
outline: none;
}
-.navbar-gitlab .header-content .title .canary-badge {
- margin-left: -8px;
-}
.navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) {
margin: 0 2px;
}
@@ -991,9 +982,6 @@ kbd {
visibility: hidden;
top: 3px;
}
-.top-nav-toggle .dropdown-icon {
- margin-right: 0.5rem;
-}
.tanuki-logo .tanuki {
fill: #e24329;
}
@@ -1116,7 +1104,7 @@ kbd {
font-weight: 600;
}
.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) {
- background-color: rgba(41, 41, 97, 0.08);
+ background-color: rgba(0, 0, 0, 0.08);
}
.nav-sidebar ul {
padding-left: 0;
@@ -1751,6 +1739,12 @@ svg.s16 {
.gl-pt-0 {
padding-top: 0;
}
+.gl-mr-auto {
+ margin-right: auto;
+}
+.gl-mr-3 {
+ margin-right: 0.5rem;
+}
.gl-ml-n2 {
margin-left: -0.25rem;
}
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index c0e2d8d44d4..edc579f48f6 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -11,6 +11,9 @@ html {
font-family: sans-serif;
line-height: 1.15;
}
+header {
+ display: block;
+}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
@@ -28,7 +31,8 @@ hr {
height: 0;
overflow: visible;
}
-h1 {
+h1,
+h3 {
margin-top: 0;
margin-bottom: 0.25rem;
}
@@ -49,26 +53,49 @@ img {
vertical-align: middle;
border-style: none;
}
+svg {
+ overflow: hidden;
+ vertical-align: middle;
+}
label {
display: inline-block;
margin-bottom: 0.5rem;
}
-input {
+button {
+ border-radius: 0;
+}
+input,
+button {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
+button,
input {
overflow: visible;
}
+button {
+ text-transform: none;
+}
+[role="button"] {
+ cursor: pointer;
+}
+button:not(:disabled),
+[type="button"]:not(:disabled),
[type="submit"]:not(:disabled) {
cursor: pointer;
}
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
padding: 0;
border-style: none;
}
+input[type="checkbox"] {
+ box-sizing: border-box;
+ padding: 0;
+}
fieldset {
min-width: 0;
padding: 0;
@@ -78,7 +105,8 @@ fieldset {
[hidden] {
display: none !important;
}
-h1 {
+h1,
+h3 {
margin-bottom: 0.25rem;
font-weight: 600;
line-height: 1.2;
@@ -87,6 +115,9 @@ h1 {
h1 {
font-size: 2.1875rem;
}
+h3 {
+ font-size: 1.53125rem;
+}
hr {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
@@ -120,23 +151,42 @@ hr {
max-width: 1140px;
}
}
-.col-sm-12,
-.col {
+.row {
+ display: flex;
+ flex-wrap: wrap;
+ margin-right: -15px;
+ margin-left: -15px;
+}
+.col-md-6,
+.col-sm-12 {
position: relative;
width: 100%;
padding-right: 15px;
padding-left: 15px;
}
-.col {
- flex-basis: 0;
- flex-grow: 1;
- max-width: 100%;
+.order-1 {
+ order: 1;
+}
+.order-12 {
+ order: 12;
}
@media (min-width: 576px) {
.col-sm-12 {
flex: 0 0 100%;
max-width: 100%;
}
+ .order-sm-1 {
+ order: 1;
+ }
+ .order-sm-12 {
+ order: 12;
+ }
+}
+@media (min-width: 768px) {
+ .col-md-6 {
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
}
.form-control {
display: block;
@@ -169,16 +219,6 @@ hr {
.form-group {
margin-bottom: 1rem;
}
-.form-row {
- display: flex;
- flex-wrap: wrap;
- margin-right: -5px;
- margin-left: -5px;
-}
-.form-row > .col {
- padding-right: 5px;
- padding-left: 5px;
-}
.btn {
display: inline-block;
font-weight: 400;
@@ -204,6 +244,137 @@ hr {
fieldset:disabled a.btn {
pointer-events: none;
}
+.btn-block {
+ display: block;
+ width: 100%;
+}
+.btn-block + .btn-block {
+ margin-top: 0.5rem;
+}
+input.btn-block[type="submit"],
+input.btn-block[type="button"] {
+ width: 100%;
+}
+.custom-control {
+ position: relative;
+ z-index: 1;
+ display: block;
+ min-height: 1.5rem;
+ padding-left: 1.5rem;
+ color-adjust: exact;
+}
+.custom-control-input {
+ position: absolute;
+ left: 0;
+ z-index: -1;
+ width: 1rem;
+ height: 1.25rem;
+ opacity: 0;
+}
+.custom-control-input:checked ~ .custom-control-label::before {
+ color: #fff;
+ border-color: #007bff;
+ background-color: #007bff;
+}
+.custom-control-input:not(:disabled):active ~ .custom-control-label::before {
+ color: #fff;
+ background-color: #b3d7ff;
+ border-color: #b3d7ff;
+}
+.custom-control-input:disabled ~ .custom-control-label {
+ color: #5e5e5e;
+}
+.custom-control-input:disabled ~ .custom-control-label::before {
+ background-color: #fafafa;
+}
+.custom-control-label {
+ position: relative;
+ margin-bottom: 0;
+ vertical-align: top;
+}
+.custom-control-label::before {
+ position: absolute;
+ top: 0.25rem;
+ left: -1.5rem;
+ display: block;
+ width: 1rem;
+ height: 1rem;
+ pointer-events: none;
+ content: "";
+ background-color: #fff;
+ border: #666 solid 1px;
+}
+.custom-control-label::after {
+ position: absolute;
+ top: 0.25rem;
+ left: -1.5rem;
+ display: block;
+ width: 1rem;
+ height: 1rem;
+ content: "";
+ background: no-repeat 50% / 50% 50%;
+}
+.custom-checkbox .custom-control-label::before {
+ border-radius: 0.25rem;
+}
+.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e");
+}
+.custom-checkbox
+ .custom-control-input:indeterminate
+ ~ .custom-control-label::before {
+ border-color: #007bff;
+ background-color: #007bff;
+}
+.custom-checkbox
+ .custom-control-input:indeterminate
+ ~ .custom-control-label::after {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e");
+}
+.custom-checkbox
+ .custom-control-input:disabled:checked
+ ~ .custom-control-label::before {
+ background-color: rgba(0, 123, 255, 0.5);
+}
+.custom-checkbox
+ .custom-control-input:disabled:indeterminate
+ ~ .custom-control-label::before {
+ background-color: rgba(0, 123, 255, 0.5);
+}
+@media (prefers-reduced-motion: reduce) {
+}
+.tab-content > .tab-pane {
+ display: none;
+}
+.tab-content > .active {
+ display: block;
+}
+.navbar {
+ position: relative;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.25rem 0.5rem;
+}
+.navbar .container {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+}
+.clearfix::after {
+ display: block;
+ clear: both;
+ content: "";
+}
+.fixed-top {
+ position: fixed;
+ top: 0;
+ right: 0;
+ left: 0;
+ z-index: 1030;
+}
.mt-3 {
margin-top: 1rem !important;
}
@@ -213,8 +384,8 @@ fieldset:disabled a.btn {
.text-nowrap {
white-space: nowrap !important;
}
-.text-center {
- text-align: center !important;
+.font-weight-normal {
+ font-weight: 400 !important;
}
.gl-form-input,
.gl-form-input.form-control {
@@ -245,19 +416,109 @@ fieldset:disabled a.btn {
.gl-form-input:disabled,
.gl-form-input.form-control:disabled {
cursor: not-allowed;
- color: #868686;
+ color: #666;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
color: #868686;
}
+.gl-form-checkbox {
+ font-size: 0.875rem;
+ line-height: 1rem;
+ color: #303030;
+}
+.gl-form-checkbox .custom-control-input:disabled,
+.gl-form-checkbox .custom-control-input:disabled ~ .custom-control-label {
+ cursor: not-allowed;
+ color: #868686;
+}
+.gl-form-checkbox.custom-control .custom-control-input ~ .custom-control-label {
+ cursor: pointer;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input
+ ~ .custom-control-label::before,
+.gl-form-checkbox.custom-control
+ .custom-control-input
+ ~ .custom-control-label::after {
+ top: 0;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input
+ ~ .custom-control-label::before {
+ background-color: #fff;
+ border-color: #868686;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input:checked
+ ~ .custom-control-label::before {
+ background-color: #1f75cb;
+ border-color: #1f75cb;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input[type="checkbox"]:checked
+ ~ .custom-control-label::after,
+.gl-form-checkbox.custom-control
+ .custom-control-input[type="checkbox"]:indeterminate
+ ~ .custom-control-label::after {
+ background: none;
+ background-color: #fff;
+ mask-repeat: no-repeat;
+ mask-position: center center;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input[type="checkbox"]:checked
+ ~ .custom-control-label::after {
+ mask-image: url('data:image/svg+xml,%3Csvg width="8" height="7" viewBox="0 0 8 7" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 3.05299L2.99123 5L7 1" stroke="white" stroke-width="2"/%3E%3C/svg%3E%0A');
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input[type="checkbox"]:indeterminate
+ ~ .custom-control-label::after {
+ mask-image: url('data:image/svg+xml,%3Csvg width="8" height="2" viewBox="0 0 8 2" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 1L8 1" stroke="white" stroke-width="2"/%3E%3C/svg%3E%0A');
+}
+.gl-form-checkbox.custom-control.custom-checkbox
+ .custom-control-input:indeterminate
+ ~ .custom-control-label::before {
+ background-color: #1f75cb;
+ border-color: #1f75cb;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input:disabled
+ ~ .custom-control-label {
+ cursor: not-allowed;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input:disabled
+ ~ .custom-control-label::before {
+ background-color: #f0f0f0;
+ border-color: #dbdbdb;
+ pointer-events: auto;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input:checked:disabled
+ ~ .custom-control-label::before,
+.gl-form-checkbox.custom-control
+ .custom-control-input:indeterminate:disabled
+ ~ .custom-control-label::before {
+ background-color: #dbdbdb;
+ border-color: #dbdbdb;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input:checked:disabled
+ ~ .custom-control-label::after,
+.gl-form-checkbox.custom-control
+ .custom-control-input:indeterminate:disabled
+ ~ .custom-control-label::after {
+ background-color: #5e5e5e;
+}
.gl-button {
display: inline-flex;
}
.gl-button:not(.btn-link):active {
text-decoration: none;
}
-.gl-button.gl-button {
+.gl-button.gl-button,
+.gl-button.gl-button.btn-block {
border-width: 0;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
@@ -273,7 +534,8 @@ fieldset:disabled a.btn {
font-size: 0.875rem;
border-radius: 0.25rem;
}
-.gl-button.gl-button .gl-button-text {
+.gl-button.gl-button .gl-button-text,
+.gl-button.gl-button.btn-block .gl-button-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -282,29 +544,39 @@ fieldset:disabled a.btn {
margin-top: -1px;
margin-bottom: -1px;
}
-.gl-button.gl-button .gl-button-icon {
+.gl-button.gl-button .gl-button-icon,
+.gl-button.gl-button.btn-block .gl-button-icon {
height: 1rem;
width: 1rem;
flex-shrink: 0;
margin-right: 0.25rem;
top: auto;
}
-.gl-button.gl-button.btn-default {
+.gl-button.gl-button.btn-default,
+.gl-button.gl-button.btn-block.btn-default {
background-color: #fff;
}
-.gl-button.gl-button.btn-default:active {
+.gl-button.gl-button.btn-default:active,
+.gl-button.gl-button.btn-default.active,
+.gl-button.gl-button.btn-block.btn-default:active,
+.gl-button.gl-button.btn-block.btn-default.active {
box-shadow: inset 0 0 0 1px #5e5e5e, 0 0 0 1px #fff, 0 0 0 3px #428fdc;
outline: none;
background-color: #dbdbdb;
}
-.gl-button.gl-button.btn-confirm {
+.gl-button.gl-button.btn-confirm,
+.gl-button.gl-button.btn-block.btn-confirm {
color: #fff;
}
-.gl-button.gl-button.btn-confirm {
+.gl-button.gl-button.btn-confirm,
+.gl-button.gl-button.btn-block.btn-confirm {
background-color: #1f75cb;
box-shadow: inset 0 0 0 1px #1068bf;
}
-.gl-button.gl-button.btn-confirm:active {
+.gl-button.gl-button.btn-confirm:active,
+.gl-button.gl-button.btn-confirm.active,
+.gl-button.gl-button.btn-block.btn-confirm:active,
+.gl-button.gl-button.btn-block.btn-confirm.active {
box-shadow: inset 0 0 0 1px #033464, 0 0 0 1px #fff, 0 0 0 3px #428fdc;
outline: none;
background-color: #0b5cad;
@@ -312,10 +584,14 @@ fieldset:disabled a.btn {
body {
font-size: 0.875rem;
}
-[type="submit"] {
+button,
+html [type="button"],
+[type="submit"],
+[role="button"] {
cursor: pointer;
}
-h1 {
+h1,
+h3 {
margin-top: 20px;
margin-bottom: 10px;
}
@@ -325,6 +601,9 @@ a {
hr {
overflow: hidden;
}
+svg {
+ vertical-align: baseline;
+}
.form-control {
font-size: 0.875rem;
}
@@ -332,15 +611,9 @@ hr {
display: none !important;
visibility: hidden !important;
}
-.hide {
- display: none;
-}
html {
overflow-y: scroll;
}
-body {
- text-decoration-skip: ink;
-}
body.navless {
background-color: #fff !important;
}
@@ -375,13 +648,34 @@ body.navless {
background-color: #f0f0f0;
box-shadow: none;
}
-.btn:active {
+.btn:active,
+.btn.active {
background-color: #eaeaea;
border-color: #e3e3e3;
color: #303030;
}
-.light {
- color: #303030;
+.btn svg {
+ height: 15px;
+ width: 15px;
+}
+.btn svg:not(:last-child) {
+ margin-right: 5px;
+}
+.btn-block {
+ width: 100%;
+ margin: 0;
+ margin-bottom: 1rem;
+}
+.btn-block.btn {
+ padding: 6px 0;
+}
+.tab-content {
+ overflow: visible;
+}
+@media (max-width: 767.98px) {
+ .tab-content {
+ isolation: isolate;
+ }
}
hr {
margin: 1.5rem 0;
@@ -419,6 +713,9 @@ input {
label {
font-weight: 600;
}
+label.custom-control-label {
+ font-weight: 400;
+}
label.label-bold {
font-weight: 600;
}
@@ -432,8 +729,25 @@ label.label-bold {
.gl-show-field-errors .form-control:not(textarea) {
height: 34px;
}
-.gl-show-field-errors .gl-field-hint {
- color: #303030;
+.navbar-empty {
+ justify-content: center;
+ height: var(--header-height, 48px);
+ background: #fff;
+ border-bottom: 1px solid #dbdbdb;
+}
+.navbar-empty .tanuki-logo,
+.navbar-empty .brand-header-logo {
+ max-height: 100%;
+}
+.tanuki-logo .tanuki {
+ fill: #e24329;
+}
+.tanuki-logo .left-cheek,
+.tanuki-logo .right-cheek {
+ fill: #fc6d26;
+}
+.tanuki-logo .chin {
+ fill: #fca326;
}
input::-moz-placeholder {
color: #868686;
@@ -445,6 +759,9 @@ input::-ms-input-placeholder {
input:-ms-input-placeholder {
color: #868686;
}
+svg {
+ fill: currentColor;
+}
.login-page .container {
max-width: 960px;
}
@@ -477,6 +794,10 @@ input:-ms-input-placeholder {
.login-page p {
font-size: 13px;
}
+.login-page .signin-text p {
+ margin-bottom: 0;
+ line-height: 1.5;
+}
.login-page .borderless .login-box,
.login-page .borderless .omniauth-container {
box-shadow: none;
@@ -549,6 +870,16 @@ input:-ms-input-placeholder {
border-top-right-radius: 4px;
border-top-left-radius: 4px;
}
+.login-page .new-session-tabs.nav-links-unboxed {
+ border-color: transparent;
+ box-shadow: none;
+}
+.login-page .new-session-tabs.nav-links-unboxed .nav-item {
+ border-left: 0;
+ border-right: 0;
+ border-bottom: 1px solid #dbdbdb;
+ background-color: transparent;
+}
.login-page .new-session-tabs.custom-provider-tabs {
flex-wrap: wrap;
}
@@ -648,14 +979,20 @@ input:-ms-input-placeholder {
}
}
-.gl-text-green-600 {
- color: #217645;
+.gl-display-flex {
+ display: flex;
}
-.gl-text-red-500 {
- color: #dd2b0e;
+.gl-display-inline-block {
+ display: inline-block;
}
-.gl-display-block {
- display: block;
+.gl-flex-wrap {
+ flex-wrap: wrap;
+}
+.gl-justify-content-center {
+ justify-content: center;
+}
+.gl-float-right {
+ float: right;
}
.gl-w-10 {
width: 3.5rem;
@@ -674,14 +1011,18 @@ input:-ms-input-placeholder {
width: 100%;
}
}
-.gl-p-4 {
- padding: 0.75rem;
+.gl-p-5 {
+ padding: 1rem;
+}
+.gl-px-5 {
+ padding-left: 1rem;
+ padding-right: 1rem;
}
.gl-pt-5 {
padding-top: 1rem;
}
-.gl-mt-2 {
- margin-top: 0.25rem;
+.gl-mt-3 {
+ margin-top: 0.5rem;
}
.gl-mt-5 {
margin-top: 1rem;
@@ -701,15 +1042,17 @@ input:-ms-input-placeholder {
.gl-mb-3 {
margin-bottom: 0.5rem;
}
-.gl-mb-5 {
- margin-bottom: 1rem;
-}
.gl-ml-auto {
margin-left: auto;
}
.gl-ml-2 {
margin-left: 0.25rem;
}
+@media (min-width: 576px) {
+ .gl-sm-mt-0 {
+ margin-top: 0;
+ }
+}
.gl-text-center {
text-align: center;
}
@@ -719,6 +1062,9 @@ input:-ms-input-placeholder {
.gl-font-weight-normal {
font-weight: 400;
}
+.gl-font-weight-bold {
+ font-weight: 600;
+}
@import "startup/cloaking";
@include cloak-startup-scss(none);
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index eeb4604f32a..4b74e449e06 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -101,7 +101,6 @@ $white-dark: #444;
$theme-indigo-50: #1a1a40;
$border-color: #4f4f4f;
-$nav-active-bg: rgba(255, 255, 255, 0.08);
:root {
color-scheme: dark;
@@ -206,7 +205,6 @@ body.gl-dark {
--black: #{$black};
--svg-status-bg: #{$white};
- --nav-active-bg: #{$nav-active-bg};
.gl-button.gl-button,
.gl-button.gl-button.btn-block {
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index 92740aaf89e..e1ba2a69420 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -60,26 +60,6 @@
}
.nav-sidebar {
- li {
- a {
- color: var(--gray-600);
- }
-
- > a:hover {
- background-color: var(--nav-active-bg);
- }
-
- &.active {
- box-shadow: none;
-
- &:not(.fly-out-top-item) {
- > a:not(.has-sub-items) {
- background-color: var(--nav-active-bg);
- }
- }
- }
- }
-
.sidebar-sub-level-items.fly-out-list {
box-shadow: none;
border: 1px solid $border-color;
@@ -92,7 +72,7 @@ aside.right-sidebar:not(.right-sidebar-merge-requests) {
}
body.gl-dark {
- @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $gray-900, $white);
+ @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $white);
.terms {
.logo-text {
diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss
index 817557f37cd..90122cec31f 100644
--- a/app/assets/stylesheets/themes/theme_blue.scss
+++ b/app/assets/stylesheets/themes/theme_blue.scss
@@ -6,7 +6,6 @@ body {
$theme-blue-200,
$theme-blue-500,
$theme-blue-700,
- $gray-900,
$theme-blue-900,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_gray.scss b/app/assets/stylesheets/themes/theme_gray.scss
index 75b111f90c7..a6cdfb36a7c 100644
--- a/app/assets/stylesheets/themes/theme_gray.scss
+++ b/app/assets/stylesheets/themes/theme_gray.scss
@@ -7,7 +7,6 @@ body {
$gray-300,
$gray-500,
$gray-900,
- $gray-900,
$white
);
}
diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss
index 7e387e97452..0300f261d64 100644
--- a/app/assets/stylesheets/themes/theme_green.scss
+++ b/app/assets/stylesheets/themes/theme_green.scss
@@ -6,7 +6,6 @@ body {
$theme-green-200,
$theme-green-500,
$theme-green-700,
- $gray-900,
$theme-green-900,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index 042e21cebd6..d644d8acc98 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -6,18 +6,22 @@
$search-and-nav-links,
$accent,
$border-and-box-shadow,
- $sidebar-text,
- $nav-svg-color,
- $color-alternate
+ $navbar-theme-color,
+ $navbar-theme-contrast-color
) {
// Set custom properties
--gl-theme-accent: #{$accent};
+ $search-and-nav-links-a20: rgba($search-and-nav-links, 0.2);
+ $search-and-nav-links-a30: rgba($search-and-nav-links, 0.3);
+ $search-and-nav-links-a40: rgba($search-and-nav-links, 0.4);
+ $search-and-nav-links-a80: rgba($search-and-nav-links, 0.8);
+
// Header
.navbar-gitlab {
- background-color: $nav-svg-color;
+ background-color: $navbar-theme-color;
.navbar-collapse {
color: $search-and-nav-links;
@@ -37,7 +41,7 @@
> button {
&:hover,
&:focus {
- background-color: rgba($search-and-nav-links, 0.2);
+ background-color: $search-and-nav-links-a20;
}
}
@@ -45,13 +49,13 @@
&.dropdown.show {
> a,
> button {
- color: $nav-svg-color;
- background-color: $color-alternate;
+ color: $navbar-theme-color;
+ background-color: $navbar-theme-contrast-color;
}
}
&.line-separator {
- border-left: 1px solid rgba($search-and-nav-links, 0.2);
+ border-left: 1px solid $search-and-nav-links-a20;
}
}
}
@@ -65,12 +69,12 @@
color: $search-and-nav-links;
&.header-search-new {
- color: $sidebar-text;
+ color: $gray-900;
}
> a {
.notification-dot {
- border: 2px solid $nav-svg-color;
+ border: 2px solid $navbar-theme-color;
}
&.header-help-dropdown-toggle {
@@ -88,7 +92,7 @@
&:hover,
&:focus {
@include media-breakpoint-up(sm) {
- background-color: rgba($search-and-nav-links, 0.2);
+ background-color: $search-and-nav-links-a20;
}
svg {
@@ -97,7 +101,7 @@
.notification-dot {
will-change: border-color, background-color;
- border-color: adjust-color($nav-svg-color, $red: 33, $green: 33, $blue: 33);
+ border-color: adjust-color($navbar-theme-color, $red: 33, $green: 33, $blue: 33);
}
&.header-help-dropdown-toggle .notification-dot {
@@ -108,12 +112,12 @@
&.active > a,
&.dropdown.show > a {
- color: $nav-svg-color;
- background-color: $color-alternate;
+ color: $navbar-theme-color;
+ background-color: $navbar-theme-contrast-color;
&:hover {
svg {
- fill: $nav-svg-color;
+ fill: $navbar-theme-color;
}
}
@@ -123,7 +127,7 @@
&.header-help-dropdown-toggle {
.notification-dot {
- background-color: $nav-svg-color;
+ background-color: $navbar-theme-color;
}
}
}
@@ -131,7 +135,7 @@
.impersonated-user,
.impersonated-user:hover {
svg {
- fill: $nav-svg-color;
+ fill: $navbar-theme-color;
}
}
}
@@ -142,30 +146,30 @@
> a {
&:hover,
&:focus {
- background-color: rgba($search-and-nav-links, 0.2);
+ background-color: $search-and-nav-links-a20;
}
}
}
.header-search {
- background-color: rgba($search-and-nav-links, 0.2) !important;
+ background-color: $search-and-nav-links-a20 !important;
border-radius: $border-radius-default;
&:hover {
- background-color: rgba($search-and-nav-links, 0.3) !important;
+ background-color: $search-and-nav-links-a30 !important;
}
svg.gl-search-box-by-type-search-icon {
- color: rgba($search-and-nav-links, 0.8);
+ color: $search-and-nav-links-a80;
}
input {
background-color: transparent;
- color: rgba($search-and-nav-links, 0.8);
- box-shadow: inset 0 0 0 1px rgba($search-and-nav-links, 0.4);
+ color: $search-and-nav-links-a80;
+ box-shadow: inset 0 0 0 1px $search-and-nav-links-a40;
&::placeholder {
- color: rgba($search-and-nav-links, 0.8);
+ color: $search-and-nav-links-a80;
}
&:focus,
@@ -178,27 +182,27 @@
.keyboard-shortcut-helper {
color: $search-and-nav-links;
- background-color: rgba($search-and-nav-links, 0.2);
+ background-color: $search-and-nav-links-a20;
}
}
.search {
form {
- background-color: rgba($search-and-nav-links, 0.2);
+ background-color: $search-and-nav-links-a20;
&:hover {
- background-color: rgba($search-and-nav-links, 0.3);
+ background-color: $search-and-nav-links-a30;
}
}
.search-input::placeholder {
- color: rgba($search-and-nav-links, 0.8);
+ color: $search-and-nav-links-a80;
}
.search-input-wrap {
.search-icon,
.clear-icon {
- fill: rgba($search-and-nav-links, 0.8);
+ fill: $search-and-nav-links-a80;
}
}
@@ -209,7 +213,7 @@
.search-input-wrap {
.search-icon {
- fill: rgba($search-and-nav-links, 0.8);
+ fill: $search-and-nav-links-a80;
}
}
}
@@ -217,7 +221,7 @@
// Sidebar
.nav-sidebar li.active > a {
- color: $sidebar-text;
+ color: $gray-900;
}
.nav-sidebar {
diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss
index 3bf6cfea650..5a27a9cfdc5 100644
--- a/app/assets/stylesheets/themes/theme_indigo.scss
+++ b/app/assets/stylesheets/themes/theme_indigo.scss
@@ -6,7 +6,6 @@ body {
$indigo-200,
$indigo-500,
$indigo-700,
- $gray-900,
$indigo-900,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss
index 771a84911b3..7cb0d98802e 100644
--- a/app/assets/stylesheets/themes/theme_light_blue.scss
+++ b/app/assets/stylesheets/themes/theme_light_blue.scss
@@ -6,7 +6,6 @@ body {
$theme-light-blue-200,
$theme-light-blue-500,
$theme-light-blue-500,
- $gray-900,
$theme-light-blue-700,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss
index ad19438d79a..a0cbec9a92b 100644
--- a/app/assets/stylesheets/themes/theme_light_gray.scss
+++ b/app/assets/stylesheets/themes/theme_light_gray.scss
@@ -6,7 +6,6 @@ body {
$gray-500,
$gray-700,
$gray-500,
- $gray-900,
$gray-50,
$gray-500
);
diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss
index 8c991a7bfb3..797279cc37b 100644
--- a/app/assets/stylesheets/themes/theme_light_green.scss
+++ b/app/assets/stylesheets/themes/theme_light_green.scss
@@ -6,7 +6,6 @@ body {
$theme-green-200,
$theme-green-500,
$theme-green-500,
- $gray-900,
$theme-light-green-700,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss
index 6c220e0459a..3632c5ad45a 100644
--- a/app/assets/stylesheets/themes/theme_light_indigo.scss
+++ b/app/assets/stylesheets/themes/theme_light_indigo.scss
@@ -6,7 +6,6 @@ body {
$indigo-200,
$indigo-500,
$indigo-500,
- $gray-900,
$indigo-700,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss
index e1a715293b4..6c10d9178f1 100644
--- a/app/assets/stylesheets/themes/theme_light_red.scss
+++ b/app/assets/stylesheets/themes/theme_light_red.scss
@@ -6,7 +6,6 @@ body {
$theme-light-red-200,
$theme-light-red-500,
$theme-light-red-500,
- $gray-900,
$theme-light-red-700,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss
index 19fd150727d..140e27de6e2 100644
--- a/app/assets/stylesheets/themes/theme_red.scss
+++ b/app/assets/stylesheets/themes/theme_red.scss
@@ -6,7 +6,6 @@ body {
$theme-red-200,
$theme-red-500,
$theme-red-700,
- $gray-900,
$theme-red-900,
$white
);
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 6bd05f90f26..bdb8f758137 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -370,3 +370,13 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
.gl-flex-flow-row-wrap {
flex-flow: row wrap;
}
+
+/*
+ * The below style will be moved to @gitlab/ui by
+ * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1963
+ */
+.gl-gap-y-3 {
+ > * + * {
+ margin-top: $gl-spacing-scale-3;
+ }
+}
diff --git a/app/components/layouts/horizontal_section_component.haml b/app/components/layouts/horizontal_section_component.haml
new file mode 100644
index 00000000000..4b5b4f1d0df
--- /dev/null
+++ b/app/components/layouts/horizontal_section_component.haml
@@ -0,0 +1,10 @@
+%div{ formatted_options }
+ .row
+ .col-lg-4
+ %h4.gl-mt-0
+ = title
+ - if description?
+ %p
+ = description
+ .col-lg-8
+ = body
diff --git a/app/components/layouts/horizontal_section_component.rb b/app/components/layouts/horizontal_section_component.rb
new file mode 100644
index 00000000000..48c960f17d9
--- /dev/null
+++ b/app/components/layouts/horizontal_section_component.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Layouts
+ class HorizontalSectionComponent < ViewComponent::Base
+ # @param [Boolean] border
+ # @param [Hash] options
+ def initialize(border: true, options: {})
+ @border = border
+ @options = options
+ end
+
+ private
+
+ renders_one :title
+ renders_one :description
+ renders_one :body
+
+ def formatted_options
+ @options.merge({ class: [('gl-border-b' if @border), @options[:class]].flatten.compact })
+ end
+ end
+end
diff --git a/app/components/pajamas/badge_component.html.haml b/app/components/pajamas/badge_component.html.haml
new file mode 100644
index 00000000000..eaadc681f0e
--- /dev/null
+++ b/app/components/pajamas/badge_component.html.haml
@@ -0,0 +1,6 @@
+- if link?
+ %a{ href: @href, **html_options }><
+ = badge_content
+- else
+ %span{ **html_options }><
+ = badge_content
diff --git a/app/components/pajamas/badge_component.rb b/app/components/pajamas/badge_component.rb
new file mode 100644
index 00000000000..244064b0e1e
--- /dev/null
+++ b/app/components/pajamas/badge_component.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module Pajamas
+ class BadgeComponent < Pajamas::Component
+ def initialize(
+ text = nil,
+ icon: nil,
+ icon_classes: [],
+ icon_only: false,
+ href: nil,
+ size: :md,
+ variant: :muted,
+ **html_options
+ )
+ @text = text.presence
+ @icon = icon.to_s.presence
+ @icon_classes = Array.wrap(icon_classes)
+ @icon_only = @icon && icon_only
+ @href = href.presence
+ @size = filter_attribute(size.to_sym, SIZE_OPTIONS, default: :md)
+ @variant = filter_attribute(variant.to_sym, VARIANT_OPTIONS, default: :muted)
+ @html_options = html_options
+ end
+
+ private
+
+ SIZE_OPTIONS = [:sm, :md, :lg].freeze
+ VARIANT_OPTIONS = [:muted, :neutral, :info, :success, :warning, :danger].freeze
+
+ delegate :sprite_icon, to: :helpers
+
+ def badge_classes
+ ["gl-badge", "badge", "badge-pill", "badge-#{@variant}", @size.to_s]
+ end
+
+ def icon_classes
+ classes = %w[gl-icon gl-badge-icon] + @icon_classes
+ classes.push("gl-mr-2") unless icon_only?
+ classes.join(" ")
+ end
+
+ def icon_only?
+ @icon_only
+ end
+
+ def link?
+ @href.present?
+ end
+
+ # Determines the rendered text content.
+ # The content slot takes presedence over the text param.
+ def text
+ content || @text
+ end
+
+ def badge_content
+ if icon_only?
+ sprite_icon(@icon, css_class: icon_classes)
+ elsif @icon.present?
+ sprite_icon(@icon, css_class: icon_classes) + text
+ else
+ text
+ end
+ end
+
+ def html_options
+ options = format_options(options: @html_options, css_classes: badge_classes)
+ options.merge!({ aria: { label: text }, role: "img" }) if icon_only?
+ options
+ end
+ end
+end
diff --git a/app/components/pajamas/button_component.rb b/app/components/pajamas/button_component.rb
index 4233e446d5b..b2dd798b718 100644
--- a/app/components/pajamas/button_component.rb
+++ b/app/components/pajamas/button_component.rb
@@ -112,7 +112,7 @@ module Pajamas
def base_attributes
attributes = {}
- attributes['disabled'] = '' if @disabled || @loading
+ attributes['disabled'] = 'disabled' if @disabled || @loading
attributes['aria-disabled'] = true if @disabled || @loading
attributes['type'] = @type unless @href
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
index 206a5b11e4b..0de2115d4d6 100644
--- a/app/controllers/abuse_reports_controller.rb
+++ b/app/controllers/abuse_reports_controller.rb
@@ -30,10 +30,7 @@ class AbuseReportsController < ApplicationController
private
def report_params
- params.require(:abuse_report).permit(%i(
- message
- user_id
- ))
+ params.require(:abuse_report).permit(:message, :user_id)
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/acme_challenges_controller.rb b/app/controllers/acme_challenges_controller.rb
index 67a39d8870b..4a7706db94e 100644
--- a/app/controllers/acme_challenges_controller.rb
+++ b/app/controllers/acme_challenges_controller.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# rubocop:disable Rails/ApplicationController
class AcmeChallengesController < ActionController::Base
def show
if acme_order
@@ -15,3 +16,4 @@ class AcmeChallengesController < ActionController::Base
@acme_order ||= PagesDomainAcmeOrder.find_by_domain_and_token(params[:domain], params[:token])
end
end
+# rubocop:enable Rails/ApplicationController
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 6f21b123eb0..b75a7c4a2dd 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -18,23 +18,23 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
- :general, :reporting, :metrics_and_profiling, :network,
- :preferences, :update, :reset_health_check_token
- ]
+ :general, :reporting, :metrics_and_profiling, :network,
+ :preferences, :update, :reset_health_check_token
+ ]
feature_category :metrics, [
- :create_self_monitoring_project,
- :status_create_self_monitoring_project,
- :delete_self_monitoring_project,
- :status_delete_self_monitoring_project
- ]
+ :create_self_monitoring_project,
+ :status_create_self_monitoring_project,
+ :delete_self_monitoring_project,
+ :status_delete_self_monitoring_project
+ ]
urgency :low, [
- :create_self_monitoring_project,
- :status_create_self_monitoring_project,
- :delete_self_monitoring_project,
- :status_delete_self_monitoring_project,
- :reset_error_tracking_access_token
- ]
+ :create_self_monitoring_project,
+ :status_create_self_monitoring_project,
+ :delete_self_monitoring_project,
+ :status_delete_self_monitoring_project,
+ :reset_error_tracking_access_token
+ ]
feature_category :source_code_management, [:repository, :clear_repository_check_states]
feature_category :continuous_integration, [:ci_cd, :reset_registration_token]
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index b0d7c8cb8f2..d66b3cb4366 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -14,7 +14,7 @@ class Admin::ApplicationsController < Admin::ApplicationController
end
def show
- @created = get_created_session
+ @created = get_created_session if Feature.disabled?('hash_oauth_secrets')
end
def new
@@ -30,9 +30,14 @@ class Admin::ApplicationsController < Admin::ApplicationController
if @application.persisted?
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
- set_created_session
+ if Feature.enabled?('hash_oauth_secrets')
+ @created = true
+ render :show
+ else
+ set_created_session
- redirect_to admin_application_url(@application)
+ redirect_to admin_application_url(@application)
+ end
else
render :new
end
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index a53e832329f..251ba9e29f2 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -57,14 +57,15 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
end
def broadcast_message_params
- params.require(:broadcast_message).permit(%i(
- theme
- ends_at
- message
- starts_at
- target_path
- broadcast_type
- dismissable
- ), target_access_levels: []).reverse_merge!(target_access_levels: [])
+ params.require(:broadcast_message)
+ .permit(%i(
+ theme
+ ends_at
+ message
+ starts_at
+ target_path
+ broadcast_type
+ dismissable
+ ), target_access_levels: []).reverse_merge!(target_access_levels: [])
end
end
diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb
index 468a1077694..ce3d769f35e 100644
--- a/app/controllers/admin/cohorts_controller.rb
+++ b/app/controllers/admin/cohorts_controller.rb
@@ -1,15 +1,20 @@
# frozen_string_literal: true
class Admin::CohortsController < Admin::ApplicationController
- include RedisTracking
+ include ProductAnalyticsTracking
feature_category :devops_reports
urgency :low
+ track_custom_event :index,
+ name: 'i_analytics_cohorts',
+ action: 'perform_analytics_usage_action',
+ label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
+ destinations: %i[redis_hll snowplow]
+
def index
@cohorts = load_cohorts
- track_cohorts_visit
end
private
@@ -22,7 +27,11 @@ class Admin::CohortsController < Admin::ApplicationController
CohortsSerializer.new.represent(cohorts_results)
end
- def track_cohorts_visit
- track_unique_redis_hll_event('i_analytics_cohorts') if trackable_html_request?
+ def tracking_namespace_source
+ nil
+ end
+
+ def tracking_project_source
+ nil
end
end
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index 8fe106249c3..37dde065e70 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -14,14 +14,7 @@ class Admin::DashboardController < Admin::ApplicationController
@groups = Group.order_id_desc.with_route.limit(10)
@notices = Gitlab::ConfigChecker::PumaRuggedChecker.check
@notices += Gitlab::ConfigChecker::ExternalDatabaseChecker.check
- @redis_versions = [
- Gitlab::Redis::Queues,
- Gitlab::Redis::SharedState,
- Gitlab::Redis::Cache,
- Gitlab::Redis::TraceChunks,
- Gitlab::Redis::RateLimiting,
- Gitlab::Redis::Sessions
- ].map(&:version).uniq
+ @redis_versions = Gitlab::Redis::ALL_CLASSES.map(&:version).uniq
end
def stats
diff --git a/app/controllers/admin/hook_logs_controller.rb b/app/controllers/admin/hook_logs_controller.rb
index aa13673095d..a283d3abb0b 100644
--- a/app/controllers/admin/hook_logs_controller.rb
+++ b/app/controllers/admin/hook_logs_controller.rb
@@ -1,34 +1,17 @@
# frozen_string_literal: true
-class Admin::HookLogsController < Admin::ApplicationController
- include ::Integrations::HooksExecution
+module Admin
+ class HookLogsController < Admin::ApplicationController
+ include WebHooks::HookLogActions
- before_action :hook, only: [:show, :retry]
- before_action :hook_log, only: [:show, :retry]
+ private
- respond_to :html
+ def hook
+ @hook ||= SystemHook.find(params[:hook_id])
+ end
- feature_category :integrations
- urgency :low, [:retry]
-
- def show
- end
-
- def retry
- result = hook.execute(hook_log.request_data, hook_log.trigger)
-
- set_hook_execution_notice(result)
-
- redirect_to edit_admin_hook_path(@hook)
- end
-
- private
-
- def hook
- @hook ||= SystemHook.find(params[:hook_id])
- end
-
- def hook_log
- @hook_log ||= hook.web_hook_logs.find(params[:id])
+ def after_retry_redirect_path
+ edit_admin_hook_path(hook)
+ end
end
end
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index 810801d4209..1dc6c68d8ca 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::HooksController < Admin::ApplicationController
- include ::Integrations::HooksExecution
+ include ::WebHooks::HookActions
before_action :hook_logs, only: :edit
@@ -27,7 +27,7 @@ class Admin::HooksController < Admin::ApplicationController
end
def hook_logs
- @hook_logs ||= hook.web_hook_logs.recent.page(params[:page])
+ @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]).without_count
end
def hook_param_names
diff --git a/app/controllers/admin/plan_limits_controller.rb b/app/controllers/admin/plan_limits_controller.rb
index 7bfbabe8dfc..2cebc059830 100644
--- a/app/controllers/admin/plan_limits_controller.rb
+++ b/app/controllers/admin/plan_limits_controller.rb
@@ -28,24 +28,25 @@ class Admin::PlanLimitsController < Admin::ApplicationController
end
def plan_limits_params
- params.require(:plan_limits).permit(%i[
- plan_id
- conan_max_file_size
- helm_max_file_size
- maven_max_file_size
- npm_max_file_size
- nuget_max_file_size
- pypi_max_file_size
- terraform_module_max_file_size
- generic_packages_max_file_size
- ci_pipeline_size
- ci_active_jobs
- ci_active_pipelines
- ci_project_subscriptions
- ci_pipeline_schedules
- ci_needs_size_limit
- ci_registered_group_runners
- ci_registered_project_runners
- ])
+ params.require(:plan_limits)
+ .permit(%i[
+ plan_id
+ conan_max_file_size
+ helm_max_file_size
+ maven_max_file_size
+ npm_max_file_size
+ nuget_max_file_size
+ pypi_max_file_size
+ terraform_module_max_file_size
+ generic_packages_max_file_size
+ ci_pipeline_size
+ ci_active_jobs
+ ci_active_pipelines
+ ci_project_subscriptions
+ ci_pipeline_schedules
+ ci_needs_size_limit
+ ci_registered_group_runners
+ ci_registered_project_runners
+ ])
end
end
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 24d7bd9ca7b..a0f72f5e58c 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -8,6 +8,10 @@ class Admin::RunnersController < Admin::ApplicationController
push_frontend_feature_flag(:admin_runners_bulk_delete)
end
+ before_action only: [:show] do
+ push_frontend_feature_flag(:enforce_runner_token_expires_at)
+ end
+
feature_category :runner
urgency :low
@@ -22,7 +26,7 @@ class Admin::RunnersController < Admin::ApplicationController
end
def update
- if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params)
+ if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success?
respond_to do |format|
format.html { redirect_to edit_admin_runner_path(@runner) }
end
@@ -39,7 +43,7 @@ class Admin::RunnersController < Admin::ApplicationController
end
def resume
- if Ci::Runners::UpdateRunnerService.new(@runner).update(active: true)
+ if Ci::Runners::UpdateRunnerService.new(@runner).execute(active: true).success?
redirect_to admin_runners_path, notice: _('Runner was successfully updated.')
else
redirect_to admin_runners_path, alert: _('Runner was not updated.')
@@ -47,7 +51,7 @@ class Admin::RunnersController < Admin::ApplicationController
end
def pause
- if Ci::Runners::UpdateRunnerService.new(@runner).update(active: false)
+ if Ci::Runners::UpdateRunnerService.new(@runner).execute(active: false).success?
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/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index e4e866a8b60..3a55fc4b951 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::SpamLogsController < Admin::ApplicationController
- feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
+ feature_category :instance_resiliency
# rubocop: disable CodeReuse/ActiveRecord
def index
diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb
index 69bcfdf4791..e97ead12f71 100644
--- a/app/controllers/admin/topics_controller.rb
+++ b/app/controllers/admin/topics_controller.rb
@@ -49,16 +49,12 @@ class Admin::TopicsController < Admin::ApplicationController
source_topic = Projects::Topic.find(merge_params[:source_topic_id])
target_topic = Projects::Topic.find(merge_params[:target_topic_id])
- begin
- ::Topics::MergeService.new(source_topic, target_topic).execute
- rescue ArgumentError => e
- return render status: :bad_request, json: { type: :alert, message: e.message }
- end
+ response = ::Topics::MergeService.new(source_topic, target_topic).execute
+ return render status: :bad_request, json: { type: :alert, message: response.message } if response.error?
message = _('Topic %{source_topic} was successfully merged into topic %{target_topic}.')
- redirect_to admin_topics_path,
- status: :found,
- notice: message % { source_topic: source_topic.name, target_topic: target_topic.name }
+ flash[:toast] = message % { source_topic: source_topic.name, target_topic: target_topic.name }
+ redirect_to admin_topics_path, status: :found
end
private
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 5cc0c8f3970..1a57d271271 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -105,7 +105,7 @@ class Admin::UsersController < Admin::ApplicationController
return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user cannot be deactivated")) if user.blocked?
return redirect_back_or_admin_user(notice: _("Successfully deactivated")) if user.deactivated?
return redirect_back_or_admin_user(notice: _("Internal users cannot be deactivated")) if user.internal?
- return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: ::User::MINIMUM_INACTIVE_DAYS }) unless user.can_be_deactivated?
+ return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: Gitlab::CurrentSettings.deactivate_dormant_users_period }) unless user.can_be_deactivated?
user.deactivate
redirect_back_or_admin_user(notice: _("Successfully deactivated"))
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index 11377df7a10..5028544795c 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -77,10 +77,10 @@ module Boards
:milestone,
:assignees,
project: [
- :route,
- {
- namespace: [:route]
- }
+ :route,
+ {
+ namespace: [:route]
+ }
],
labels: [:priorities],
notes: [:award_emoji, :author]
diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb
index 4e5af1945a4..6139168d29f 100644
--- a/app/controllers/chaos_controller.rb
+++ b/app/controllers/chaos_controller.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# rubocop:disable Rails/ApplicationController
class ChaosController < ActionController::Base
before_action :validate_chaos_secret, unless: :development_or_test?
@@ -93,3 +94,4 @@ class ChaosController < ActionController::Base
Rails.env.development? || Rails.env.test?
end
end
+# rubocop:enable Rails/ApplicationController
diff --git a/app/controllers/concerns/accepts_pending_invitations.rb b/app/controllers/concerns/accepts_pending_invitations.rb
index 53dec698fa0..1723058c217 100644
--- a/app/controllers/concerns/accepts_pending_invitations.rb
+++ b/app/controllers/concerns/accepts_pending_invitations.rb
@@ -8,7 +8,6 @@ module AcceptsPendingInvitations
if user.pending_invitations.load.any?
user.accept_pending_invitations!
- clear_stored_location_for(user: user)
after_pending_invitations_hook
end
end
@@ -16,10 +15,4 @@ module AcceptsPendingInvitations
def after_pending_invitations_hook
# no-op
end
-
- def clear_stored_location_for(user:)
- session_key = stored_location_key_for(user)
-
- session.delete(session_key)
- end
end
diff --git a/app/controllers/concerns/dependency_proxy/group_access.rb b/app/controllers/concerns/dependency_proxy/group_access.rb
index 45392625e45..e9fb2563e42 100644
--- a/app/controllers/concerns/dependency_proxy/group_access.rb
+++ b/app/controllers/concerns/dependency_proxy/group_access.rb
@@ -20,3 +20,5 @@ module DependencyProxy
end
end
end
+
+DependencyProxy::GroupAccess.prepend_mod_with('DependencyProxy::GroupAccess')
diff --git a/app/controllers/concerns/harbor/access.rb b/app/controllers/concerns/harbor/access.rb
index 70de72f15fc..211566aeda7 100644
--- a/app/controllers/concerns/harbor/access.rb
+++ b/app/controllers/concerns/harbor/access.rb
@@ -17,7 +17,7 @@ module Harbor
private
def harbor_registry_enabled!
- render_404 unless Feature.enabled?(:harbor_registry_integration)
+ render_404 unless Feature.enabled?(:harbor_registry_integration, defined?(group) ? group : project)
end
def authorize_read_harbor_registry!
diff --git a/app/controllers/concerns/integrations/hooks_execution.rb b/app/controllers/concerns/integrations/hooks_execution.rb
deleted file mode 100644
index fb26840168f..00000000000
--- a/app/controllers/concerns/integrations/hooks_execution.rb
+++ /dev/null
@@ -1,95 +0,0 @@
-# frozen_string_literal: true
-
-module Integrations::HooksExecution
- extend ActiveSupport::Concern
-
- included do
- attr_writer :hooks, :hook
- end
-
- def index
- self.hooks = relation.select(&:persisted?)
- self.hook = relation.new
- end
-
- def create
- self.hook = relation.new(hook_params)
- hook.save
-
- unless hook.valid?
- self.hooks = relation.select(&:persisted?)
- flash[:alert] = hook.errors.full_messages.join.html_safe
- end
-
- redirect_to action: :index
- end
-
- def update
- if hook.update(hook_params)
- flash[:notice] = _('Hook was successfully updated.')
- redirect_to action: :index
- else
- render 'edit'
- end
- end
-
- def destroy
- destroy_hook(hook)
-
- redirect_to action: :index, status: :found
- end
-
- def edit
- redirect_to(action: :index) unless hook
- end
-
- private
-
- def hook_params
- permitted = hook_param_names + trigger_values
- permitted << { url_variables: [:key, :value] }
-
- ps = params.require(:hook).permit(*permitted).to_h
-
- ps[:url_variables] = ps[:url_variables].to_h { [_1[:key], _1[:value].presence] } if ps.key?(:url_variables)
-
- if action_name == 'update' && ps.key?(:url_variables)
- supplied = ps[:url_variables]
- ps[:url_variables] = hook.url_variables.merge(supplied).compact
- end
-
- ps
- end
-
- def hook_param_names
- %i[enable_ssl_verification token url push_events_branch_filter]
- end
-
- def destroy_hook(hook)
- result = WebHooks::DestroyService.new(current_user).execute(hook)
-
- if result[:status] == :success
- flash[:notice] =
- if result[:async]
- _("%{hook_type} was scheduled for deletion") % { hook_type: hook.model_name.human }
- else
- _("%{hook_type} was deleted") % { hook_type: hook.model_name.human }
- end
- else
- flash[:alert] = result[:message]
- end
- end
-
- def set_hook_execution_notice(result)
- http_status = result[:http_status]
- message = result[:message]
-
- if http_status && http_status >= 200 && http_status < 400
- flash[:notice] = "Hook executed successfully: HTTP #{http_status}"
- elsif http_status
- flash[:alert] = "Hook executed successfully but returned HTTP #{http_status} #{message}"
- else
- flash[:alert] = "Hook execution failed: #{message}"
- end
- end
-end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index f1d80e37674..7c3401a7e90 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -193,7 +193,10 @@ module IssuableActions
end
def render_cached_discussions(discussions, serializer, cache_context)
- render_cached(discussions, with: serializer, cache_context: -> (_) { cache_context }, context: self)
+ render_cached(discussions,
+ with: serializer,
+ cache_context: -> (_) { cache_context },
+ context: self)
end
def paginated_discussions
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index fb11bece79c..8a67b62f28b 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -150,7 +150,11 @@ module MembershipActions
when 'only'
[:inherited]
else
- [:inherited, :direct]
+ if Feature.enabled?(:webui_members_inherited_users, current_user)
+ [:inherited, :direct, :shared_from_groups]
+ else
+ [:inherited, :direct]
+ end
end
end
end
diff --git a/app/controllers/concerns/packages_access.rb b/app/controllers/concerns/packages_access.rb
index 6df2e064bb2..a7d16a5bc88 100644
--- a/app/controllers/concerns/packages_access.rb
+++ b/app/controllers/concerns/packages_access.rb
@@ -15,6 +15,6 @@ module PackagesAccess
end
def verify_read_package!
- authorize_read_package!(project)
+ access_denied! unless can?(current_user, :read_package, project&.packages_policy_subject)
end
end
diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb
index 260b433cc6f..8e936782e5a 100644
--- a/app/controllers/concerns/product_analytics_tracking.rb
+++ b/app/controllers/concerns/product_analytics_tracking.rb
@@ -66,7 +66,17 @@ module ProductAnalyticsTracking
i_analytics_dev_ops_score: :route_hll_to_snowplow_phase2,
p_analytics_merge_request: :route_hll_to_snowplow_phase2,
i_analytics_instance_statistics: :route_hll_to_snowplow_phase2,
- g_analytics_contribution: :route_hll_to_snowplow_phase2
+ g_analytics_contribution: :route_hll_to_snowplow_phase2,
+ p_analytics_pipelines: :route_hll_to_snowplow_phase2,
+ p_analytics_code_reviews: :route_hll_to_snowplow_phase2,
+ p_analytics_valuestream: :route_hll_to_snowplow_phase2,
+ p_analytics_insights: :route_hll_to_snowplow_phase2,
+ p_analytics_issues: :route_hll_to_snowplow_phase2,
+ p_analytics_repo: :route_hll_to_snowplow_phase2,
+ g_analytics_insights: :route_hll_to_snowplow_phase2,
+ g_analytics_issues: :route_hll_to_snowplow_phase2,
+ g_analytics_productivity: :route_hll_to_snowplow_phase2,
+ i_analytics_cohorts: :route_hll_to_snowplow_phase2
}
Feature.enabled?(events_to_ff[event.to_sym], tracking_namespace_source)
diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb
index 1a3e7136481..782cae53c3f 100644
--- a/app/controllers/concerns/verifies_with_email.rb
+++ b/app/controllers/concerns/verifies_with_email.rb
@@ -7,11 +7,9 @@ module VerifiesWithEmail
extend ActiveSupport::Concern
include ActionView::Helpers::DateHelper
- TOKEN_LENGTH = 6
- TOKEN_VALID_FOR_MINUTES = 60
-
included do
prepend_before_action :verify_with_email, only: :create, unless: -> { two_factor_enabled? }
+ skip_before_action :required_signup_info, only: :successful_verification
end
def verify_with_email
@@ -76,7 +74,8 @@ module VerifiesWithEmail
def send_verification_instructions(user)
return if send_rate_limited?(user)
- raw_token, encrypted_token = generate_token
+ service = Users::EmailVerification::GenerateTokenService.new(attr: :unlock_token)
+ raw_token, encrypted_token = service.execute
user.unlock_token = encrypted_token
user.lock_access!({ send_instructions: false })
send_verification_instructions_email(user, raw_token)
@@ -88,27 +87,20 @@ module VerifiesWithEmail
Notify.verification_instructions_email(
user.id,
token: token,
- expires_in: TOKEN_VALID_FOR_MINUTES).deliver_later
+ expires_in: Users::EmailVerification::ValidateTokenService::TOKEN_VALID_FOR_MINUTES).deliver_later
log_verification(user, :instructions_sent)
end
def verify_token(user, token)
- return handle_verification_failure(user, :rate_limited) if verification_rate_limited?(user)
- return handle_verification_failure(user, :invalid) unless valid_token?(user, token)
- return handle_verification_failure(user, :expired) if expired_token?(user)
-
- handle_verification_success(user)
- end
-
- def generate_token
- raw_token = SecureRandom.random_number(10**TOKEN_LENGTH).to_s.rjust(TOKEN_LENGTH, '0')
- encrypted_token = digest_token(raw_token)
- [raw_token, encrypted_token]
- end
+ service = Users::EmailVerification::ValidateTokenService.new(attr: :unlock_token, user: user, token: token)
+ result = service.execute
- def digest_token(token)
- Devise.token_generator.digest(User, :unlock_token, token)
+ if result[:status] == :success
+ handle_verification_success(user)
+ else
+ handle_verification_failure(user, result[:reason], result[:message])
+ end
end
def render_sign_in_rate_limited
@@ -122,44 +114,17 @@ module VerifiesWithEmail
distance_of_time_in_words(interval_in_seconds)
end
- def verification_rate_limited?(user)
- Gitlab::ApplicationRateLimiter.throttled?(:email_verification, scope: user.unlock_token)
- end
-
def send_rate_limited?(user)
Gitlab::ApplicationRateLimiter.throttled?(:email_verification_code_send, scope: user)
end
- def expired_token?(user)
- user.locked_at < (Time.current - TOKEN_VALID_FOR_MINUTES.minutes)
- end
-
- def valid_token?(user, token)
- user.unlock_token == digest_token(token)
- end
-
- def handle_verification_failure(user, reason)
- message = case reason
- when :rate_limited
- s_("IdentityVerification|You've reached the maximum amount of tries. "\
- 'Wait %{interval} or resend a new code and try again.') % { interval: email_verification_interval }
- when :expired
- s_('IdentityVerification|The code has expired. Resend a new code and try again.')
- when :invalid
- s_('IdentityVerification|The code is incorrect. Enter it again, or resend a new code.')
- end
-
+ def handle_verification_failure(user, reason, message)
user.errors.add(:base, message)
log_verification(user, :failed_attempt, reason)
prompt_for_email_verification(user)
end
- def email_verification_interval
- interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:email_verification][:interval]
- distance_of_time_in_words(interval_in_seconds)
- end
-
def handle_verification_success(user)
user.unlock_access!
log_verification(user, :successful)
diff --git a/app/controllers/concerns/web_hooks/hook_actions.rb b/app/controllers/concerns/web_hooks/hook_actions.rb
new file mode 100644
index 00000000000..ea11f13c7ef
--- /dev/null
+++ b/app/controllers/concerns/web_hooks/hook_actions.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module WebHooks
+ module HookActions
+ extend ActiveSupport::Concern
+ include HookExecutionNotice
+
+ included do
+ attr_writer :hooks, :hook
+ end
+
+ def index
+ self.hooks = relation.select(&:persisted?)
+ self.hook = relation.new
+ end
+
+ def create
+ self.hook = relation.new(hook_params)
+ hook.save
+
+ unless hook.valid?
+ self.hooks = relation.select(&:persisted?)
+ flash[:alert] = hook.errors.full_messages.join.html_safe
+ end
+
+ redirect_to action: :index
+ end
+
+ def update
+ if hook.update(hook_params)
+ flash[:notice] = _('Hook was successfully updated.')
+ redirect_to action: :index
+ else
+ render 'edit'
+ end
+ end
+
+ def destroy
+ destroy_hook(hook)
+
+ redirect_to action: :index, status: :found
+ end
+
+ def edit
+ redirect_to(action: :index) unless hook
+ end
+
+ private
+
+ def hook_params
+ permitted = hook_param_names + trigger_values
+ permitted << { url_variables: [:key, :value] }
+
+ ps = params.require(:hook).permit(*permitted).to_h
+
+ ps[:url_variables] = ps[:url_variables].to_h { [_1[:key], _1[:value].presence] } if ps.key?(:url_variables)
+
+ if action_name == 'update' && ps.key?(:url_variables)
+ supplied = ps[:url_variables]
+ ps[:url_variables] = hook.url_variables.merge(supplied).compact
+ end
+
+ ps
+ end
+
+ def hook_param_names
+ %i[enable_ssl_verification token url push_events_branch_filter]
+ end
+
+ def destroy_hook(hook)
+ result = WebHooks::DestroyService.new(current_user).execute(hook)
+
+ if result[:status] == :success
+ flash[:notice] =
+ if result[:async]
+ format(_("%{hook_type} was scheduled for deletion"), hook_type: hook.model_name.human)
+ else
+ format(_("%{hook_type} was deleted"), hook_type: hook.model_name.human)
+ end
+ else
+ flash[:alert] = result[:message]
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/web_hooks/hook_execution_notice.rb b/app/controllers/concerns/web_hooks/hook_execution_notice.rb
new file mode 100644
index 00000000000..d651313b30d
--- /dev/null
+++ b/app/controllers/concerns/web_hooks/hook_execution_notice.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module WebHooks
+ module HookExecutionNotice
+ private
+
+ def set_hook_execution_notice(result)
+ http_status = result[:http_status]
+ message = result[:message]
+
+ if http_status && http_status >= 200 && http_status < 400
+ flash[:notice] = "Hook executed successfully: HTTP #{http_status}"
+ elsif http_status
+ flash[:alert] = "Hook executed successfully but returned HTTP #{http_status} #{message}"
+ else
+ flash[:alert] = "Hook execution failed: #{message}"
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/web_hooks/hook_log_actions.rb b/app/controllers/concerns/web_hooks/hook_log_actions.rb
new file mode 100644
index 00000000000..f3378d7c857
--- /dev/null
+++ b/app/controllers/concerns/web_hooks/hook_log_actions.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module WebHooks
+ module HookLogActions
+ extend ActiveSupport::Concern
+ include HookExecutionNotice
+
+ included do
+ before_action :hook, only: [:show, :retry]
+ before_action :hook_log, only: [:show, :retry]
+
+ respond_to :html
+
+ feature_category :integrations
+ urgency :low, [:retry]
+ end
+
+ def show
+ hide_search_settings
+ end
+
+ def retry
+ execute_hook
+ redirect_to after_retry_redirect_path
+ end
+
+ private
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def hook_log
+ @hook_log ||= hook.web_hook_logs.find(params[:id])
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ def execute_hook
+ result = hook.execute(hook_log.request_data, hook_log.trigger)
+ set_hook_execution_notice(result)
+ end
+
+ def hide_search_settings
+ @hide_search_settings ||= true
+ end
+ end
+end
diff --git a/app/controllers/groups/observability_controller.rb b/app/controllers/groups/observability_controller.rb
new file mode 100644
index 00000000000..5b6503494c4
--- /dev/null
+++ b/app/controllers/groups/observability_controller.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+module Groups
+ class ObservabilityController < Groups::ApplicationController
+ feature_category :tracing
+
+ content_security_policy do |p|
+ next if p.directives.blank?
+
+ default_frame_src = p.directives['frame-src'] || p.directives['default-src']
+
+ # When ObservabilityUI is not authenticated, it needs to be able to redirect to the GL sign-in page, hence 'self'
+ frame_src_values = Array.wrap(default_frame_src) | [ObservabilityController.observability_url, "'self'"]
+
+ p.frame_src(*frame_src_values)
+ end
+
+ before_action :check_observability_allowed, only: :index
+
+ def index
+ # Format: https://observe.gitlab.com/-/GROUP_ID
+ @observability_iframe_src = "#{ObservabilityController.observability_url}/-/#{@group.id}"
+
+ # Uncomment below for testing with local GDK
+ # @observability_iframe_src = "#{ObservabilityController.observability_url}/9970?groupId=14485840"
+
+ render layout: 'group', locals: { base_layout: 'layouts/fullscreen' }
+ end
+
+ private
+
+ def self.observability_url
+ return ENV['OVERRIDE_OBSERVABILITY_URL'] if ENV['OVERRIDE_OBSERVABILITY_URL']
+ # TODO Make observability URL configurable https://gitlab.com/gitlab-org/opstrace/opstrace-ui/-/issues/80
+ return "https://staging.observe.gitlab.com" if Gitlab.staging?
+
+ "https://observe.gitlab.com"
+ end
+
+ def check_observability_allowed
+ return render_404 unless self.class.observability_url.present?
+
+ render_404 unless can?(current_user, :read_observability, @group)
+ end
+ end
+end
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index aeb54527c69..652f12e34ba 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -5,12 +5,17 @@ class Groups::RunnersController < Groups::ApplicationController
before_action :authorize_admin_group_runners!, only: [:edit, :update, :destroy, :pause, :resume]
before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show]
+ before_action only: [:show] do
+ push_frontend_feature_flag(:enforce_runner_token_expires_at)
+ end
+
feature_category :runner
urgency :low
def index
finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group })
@group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000)
+ @group_runner_registration_token = @group.runners_token if can?(current_user, :register_group_runners, group)
Gitlab::Tracking.event(self.class.name, 'index', user: current_user, namespace: @group)
end
@@ -22,7 +27,7 @@ class Groups::RunnersController < Groups::ApplicationController
end
def update
- if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params)
+ if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success?
redirect_to group_runner_path(@group, @runner), notice: _('Runner was successfully updated.')
else
render 'edit'
diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb
index bfe61696e0f..3557d485422 100644
--- a/app/controllers/groups/settings/applications_controller.rb
+++ b/app/controllers/groups/settings/applications_controller.rb
@@ -16,7 +16,7 @@ module Groups
end
def show
- @created = get_created_session
+ @created = get_created_session if Feature.disabled?('hash_oauth_secrets')
end
def edit
@@ -28,9 +28,15 @@ module Groups
if @application.persisted?
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
- set_created_session
+ if Feature.enabled?('hash_oauth_secrets')
- redirect_to group_settings_application_url(@group, @application)
+ @created = true
+ render :show
+ else
+ set_created_session
+
+ redirect_to group_settings_application_url(@group, @application)
+ end
else
set_index_vars
render :index
diff --git a/app/controllers/groups/settings/repository_controller.rb b/app/controllers/groups/settings/repository_controller.rb
index b0431c31179..cb62ea2a543 100644
--- a/app/controllers/groups/settings/repository_controller.rb
+++ b/app/controllers/groups/settings/repository_controller.rb
@@ -5,8 +5,9 @@ module Groups
class RepositoryController < Groups::ApplicationController
layout 'group_settings'
skip_cross_project_access_check :show
- before_action :authorize_create_deploy_token!
- before_action :define_deploy_token_variables
+ before_action :authorize_create_deploy_token!, only: :create_deploy_token
+ before_action :authorize_access!, only: :show
+ before_action :define_deploy_token_variables, if: -> { can?(current_user, :create_deploy_token, @group) }
before_action do
push_frontend_feature_flag(:ajax_new_deploy_token, @group)
end
@@ -16,13 +17,13 @@ module Groups
def create_deploy_token
result = Groups::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute
- @new_deploy_token = result[:deploy_token]
if result[:status] == :success
+ @created_deploy_token = result[:deploy_token]
respond_to do |format|
format.json do
# IMPORTANT: It's a security risk to expose the token value more than just once here!
- json = API::Entities::DeployTokenWithToken.represent(@new_deploy_token).as_json
+ json = API::Entities::DeployTokenWithToken.represent(@created_deploy_token).as_json
render json: json, status: result[:http_status]
end
format.html do
@@ -31,6 +32,7 @@ module Groups
end
end
else
+ @new_deploy_token = result[:deploy_token]
respond_to do |format|
format.json { render json: { message: result[:message] }, status: result[:http_status] }
format.html do
@@ -43,6 +45,10 @@ module Groups
private
+ def authorize_access!
+ authorize_admin_group!
+ end
+
def define_deploy_token_variables
@deploy_tokens = @group.deploy_tokens.active
@@ -55,3 +61,5 @@ module Groups
end
end
end
+
+Groups::Settings::RepositoryController.prepend_mod
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 32b187c3260..9316204d89c 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -49,9 +49,9 @@ class GroupsController < Groups::ApplicationController
layout :determine_layout
feature_category :subgroups, [
- :index, :new, :create, :show, :edit, :update,
- :destroy, :details, :transfer, :activity
- ]
+ :index, :new, :create, :show, :edit, :update,
+ :destroy, :details, :transfer, :activity
+ ]
feature_category :team_planning, [:issues, :issues_calendar, :preview_markdown]
feature_category :code_review, [:merge_requests, :unfoldered_environment_names]
@@ -276,6 +276,7 @@ class GroupsController < Groups::ApplicationController
:avatar,
:description,
:emails_disabled,
+ :show_diff_preview_in_email,
:mentions_disabled,
:lfs_enabled,
:name,
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
index 071378f266e..5fac7c0d663 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# rubocop:disable Rails/ApplicationController
class HealthController < ActionController::Base
protect_from_forgery with: :exception, prepend: true
include RequiresWhitelistedMonitoringClient
@@ -11,13 +12,7 @@ class HealthController < ActionController::Base
ALL_CHECKS = [
*CHECKS,
Gitlab::HealthChecks::DbCheck,
- Gitlab::HealthChecks::Redis::RedisCheck,
- Gitlab::HealthChecks::Redis::CacheCheck,
- Gitlab::HealthChecks::Redis::QueuesCheck,
- Gitlab::HealthChecks::Redis::SharedStateCheck,
- Gitlab::HealthChecks::Redis::TraceChunksCheck,
- Gitlab::HealthChecks::Redis::RateLimitingCheck,
- Gitlab::HealthChecks::Redis::SessionsCheck,
+ *Gitlab::HealthChecks::Redis::ALL_INSTANCE_CHECKS,
Gitlab::HealthChecks::GitalyCheck
].freeze
@@ -45,3 +40,4 @@ class HealthController < ActionController::Base
render json: result.json, status: result.http_status
end
end
+# rubocop:enable Rails/ApplicationController
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index 1508531828d..9635e476510 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -12,8 +12,7 @@ class HelpController < ApplicationController
YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.freeze
def index
- # Remove YAML frontmatter so that it doesn't look weird
- @help_index = File.read(path_to_doc('index.md')).sub(YAML_FRONT_MATTER_REGEXP, '')
+ @help_index = get_markdown_without_frontmatter(path_to_doc('index.md'))
# Prefix Markdown links with `help/` unless they are external links.
# '//' not necessarily part of URL, e.g., mailto:mail@example.com
@@ -59,8 +58,25 @@ class HelpController < ApplicationController
@instance_configuration = InstanceConfiguration.new
end
+ def drawers
+ @clean_path = Rack::Utils.clean_path_info(params[:markdown_file])
+ @path = path_to_doc("#{@clean_path}.md")
+
+ if File.exist?(@path)
+ render :drawers, formats: :html, layout: false
+ else
+ head :not_found
+ end
+ end
+
private
+ # Remove YAML frontmatter so that it doesn't look weird
+ helper_method :get_markdown_without_frontmatter
+ def get_markdown_without_frontmatter(path)
+ File.read(path).gsub(YAML_FRONT_MATTER_REGEXP, '')
+ end
+
def redirect_to_documentation_website?
Gitlab::UrlSanitizer.valid_web?(documentation_url)
end
@@ -100,8 +116,7 @@ class HelpController < ApplicationController
path = path_to_doc("#{@path}.md")
if File.exist?(path)
- # Remove YAML frontmatter so that it doesn't look weird
- @markdown = File.read(path).gsub(YAML_FRONT_MATTER_REGEXP, '')
+ @markdown = get_markdown_without_frontmatter(path)
render :show, formats: :html
else
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index 9fcb8385312..58a985cbc46 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -13,6 +13,7 @@ class IdeController < ApplicationController
push_frontend_feature_flag(:build_service_proxy)
push_frontend_feature_flag(:schema_linting)
push_frontend_feature_flag(:reject_unsigned_commits_by_gitlab)
+ push_frontend_feature_flag(:vscode_web_ide, current_user)
define_index_vars
end
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 9cc58ce542c..8a3e6809736 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -130,7 +130,7 @@ class Import::GithubController < Import::BaseController
if sanitized_filter_param
client.search_repos_by_name(sanitized_filter_param, pagination_options)[:items]
else
- client.octokit.repos(nil, pagination_options)
+ client.repos(pagination_options)
end
else
filtered(client.repos)
diff --git a/app/controllers/jira_connect/oauth_callbacks_controller.rb b/app/controllers/jira_connect/oauth_callbacks_controller.rb
index f603a563402..e1a47a12b6d 100644
--- a/app/controllers/jira_connect/oauth_callbacks_controller.rb
+++ b/app/controllers/jira_connect/oauth_callbacks_controller.rb
@@ -7,5 +7,7 @@
class JiraConnect::OauthCallbacksController < ApplicationController
feature_category :integrations
+ skip_before_action :authenticate_user!
+
def index; end
end
diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb
index 623113f8413..9305f46c39e 100644
--- a/app/controllers/jira_connect/subscriptions_controller.rb
+++ b/app/controllers/jira_connect/subscriptions_controller.rb
@@ -64,10 +64,12 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
private
def allow_self_managed_content_security_policy
+ return unless Feature.enabled?(:jira_connect_oauth_self_managed)
+
return unless current_jira_installation.instance_url?
request.content_security_policy.directives['connect-src'] ||= []
- request.content_security_policy.directives['connect-src'] << Gitlab::Utils.append_path(current_jira_installation.instance_url, '/-/jira_connect/oauth_application_ids')
+ request.content_security_policy.directives['connect-src'].concat(allowed_instance_connect_src)
end
def create_service
@@ -77,4 +79,11 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
def allow_rendering_in_iframe
response.headers.delete('X-Frame-Options')
end
+
+ def allowed_instance_connect_src
+ [
+ Gitlab::Utils.append_path(current_jira_installation.instance_url, '/-/jira_connect/'),
+ Gitlab::Utils.append_path(current_jira_installation.instance_url, '/api/')
+ ]
+ end
end
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 84f5632854b..7211eebdb4b 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -78,7 +78,11 @@ class JwtController < ApplicationController
end
def additional_params
- { scopes: scopes_param, deploy_token: @authentication_result.deploy_token }.compact
+ {
+ scopes: scopes_param,
+ deploy_token: @authentication_result.deploy_token,
+ auth_type: @authentication_result.type
+ }.compact
end
# We have to parse scope here, because Docker Client does not send an array of scopes,
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
index a0c307a0a03..bfd6181a940 100644
--- a/app/controllers/metrics_controller.rb
+++ b/app/controllers/metrics_controller.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# rubocop:disable Rails/ApplicationController
class MetricsController < ActionController::Base
include RequiresWhitelistedMonitoringClient
@@ -34,3 +35,4 @@ class MetricsController < ActionController::Base
)
end
end
+# rubocop:enable Rails/ApplicationController
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index a996bad3fac..ff466fd5fbb 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -25,7 +25,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
end
def show
- @created = get_created_session
+ @created = get_created_session if Feature.disabled?('hash_oauth_secrets')
end
def create
@@ -34,9 +34,14 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
if @application.persisted?
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
- set_created_session
+ if Feature.enabled?('hash_oauth_secrets')
+ @created = true
+ render :show
+ else
+ set_created_session
- redirect_to oauth_application_url(@application)
+ redirect_to oauth_application_url(@application)
+ end
else
set_index_vars
render :index
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 07d786ab060..8ed67c26f19 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -65,7 +65,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
add_pagination_headers(tokens)
end
- ::API::Entities::PersonalAccessTokenWithDetails.represent(tokens)
+ ::PersonalAccessTokenSerializer.new.represent(tokens)
end
def add_pagination_headers(relation)
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index dd1ac526b89..e3704b77adc 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -137,7 +137,7 @@ class ProfilesController < Profiles::ApplicationController
:pronouns,
:pronunciation,
:validation_password,
- status: [:emoji, :message, :availability]
+ status: [:emoji, :message, :availability, :clear_status_after]
]
end
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index 5bfda526fb0..2a20c67a23d 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -23,11 +23,10 @@ class Projects::BlameController < Projects::ApplicationController
environment_params[:find_latest] = true
@environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last
- blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page))
+ blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page, :no_pagination))
@blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate!
-
- render locals: { blame_pagination: blame_service.pagination }
+ @blame_pagination = blame_service.pagination
end
end
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index 6160dafb177..63c1378ad11 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -5,13 +5,17 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
include ActionView::Helpers::TextHelper
include CycleAnalyticsParams
include GracefulTimeoutHandling
- include RedisTracking
+ include ProductAnalyticsTracking
extend ::Gitlab::Utils::Override
before_action :authorize_read_cycle_analytics!
before_action :load_value_stream, only: :show
- track_redis_hll_event :show, name: 'p_analytics_valuestream'
+ track_custom_event :show,
+ name: 'p_analytics_valuestream',
+ action: 'perform_analytics_usage_action',
+ label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
+ destinations: %i[redis_hll snowplow]
feature_category :planning_analytics
urgency :low
@@ -54,4 +58,12 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
permissions: @cycle_analytics.permissions(user: current_user)
}
end
+
+ def tracking_namespace_source
+ project.namespace
+ end
+
+ def tracking_project_source
+ project
+ end
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 7ef9fd9daed..4f037cc843e 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -5,7 +5,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
# into app/controllers/projects/metrics_dashboard_controller.rb
# See https://gitlab.com/gitlab-org/gitlab/-/issues/226002 for more details.
+ MIN_SEARCH_LENGTH = 3
+
include MetricsDashboard
+ include ProductAnalyticsTracking
layout 'project'
@@ -26,6 +29,18 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
after_action :expire_etag_cache, only: [:cancel_auto_stop]
+ track_event :index,
+ :folder,
+ :show,
+ :new,
+ :edit,
+ :create,
+ :update,
+ :stop,
+ :cancel_auto_stop,
+ :terminal,
+ name: 'users_visiting_environments_pages'
+
feature_category :continuous_delivery
urgency :low
@@ -35,12 +50,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
- @environments = project.environments
- .with_state(params[:scope] || :available)
+ @environments = search_environments.with_state(params[:scope] || :available)
+ environments_count_by_state = search_environments.count_by_state
Gitlab::PollingInterval.set_header(response, interval: 3_000)
- environments_count_by_state = project.environments.count_by_state
-
render json: {
environments: serialize_environments(request, response, params[:nested]),
review_app: serialize_review_app,
@@ -59,7 +72,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
- folder_environments = project.environments.where(environment_type: params[:id])
+ folder_environments = search_environments(type: params[:id])
+
@environments = folder_environments.with_state(params[:scope] || :available)
.order(:name)
@@ -236,6 +250,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@environment ||= project.environments.find(params[:id])
end
+ def search_environments(type: nil)
+ search = params[:search] if params[:search] && params[:search].length >= MIN_SEARCH_LENGTH
+
+ @search_environments ||=
+ Environments::EnvironmentsFinder.new(project,
+ current_user,
+ type: type,
+ search: search).execute
+ end
+
def metrics_params
params.require([:start, :end])
end
diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb
index d1eb86c5e49..dfb73821b0f 100644
--- a/app/controllers/projects/google_cloud/base_controller.rb
+++ b/app/controllers/projects/google_cloud/base_controller.rb
@@ -12,7 +12,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
def admin_project_google_cloud!
unless can?(current_user, :admin_project_google_cloud, project)
- track_event('admin_project_google_cloud!', 'error_access_denied', 'invalid_user')
+ track_event(:error_invalid_user)
access_denied!
end
end
@@ -20,11 +20,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
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!',
- 'error_access_denied',
- { reason: 'google_oauth2_not_configured', config: config }
- )
+ track_event(:error_google_oauth2_not_enabled)
access_denied! 'This GitLab instance not configured for Google Oauth2.'
end
end
@@ -35,7 +31,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
enabled_for_project = Feature.enabled?(:incubation_5mp_google_cloud, project)
feature_is_enabled = enabled_for_user || enabled_for_group || enabled_for_project
unless feature_is_enabled
- track_event('feature_flag_enabled!', 'error_access_denied', 'feature_flag_not_enabled')
+ track_event(:error_feature_flag_not_enabled)
access_denied!
end
end
@@ -69,16 +65,14 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
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)
+ def track_event(action, label = nil)
+ Gitlab::Tracking.event(
+ self.class.name,
+ action.to_s,
+ label: label,
+ project: project,
+ user: current_user
+ )
end
def gcp_projects
diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb
index 8d252c35031..06a6674d578 100644
--- a/app/controllers/projects/google_cloud/configuration_controller.rb
+++ b/app/controllers/projects/google_cloud/configuration_controller.rb
@@ -16,7 +16,7 @@ module Projects
revokeOauthUrl: revoke_oauth_url
}
@js_data = js_data.to_json
- track_event('configuration#index', 'success', js_data)
+ track_event(:render_page)
end
private
diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb
index 7b1cf6e5ce1..8f7554f248b 100644
--- a/app/controllers/projects/google_cloud/databases_controller.rb
+++ b/app/controllers/projects/google_cloud/databases_controller.rb
@@ -3,14 +3,139 @@
module Projects
module GoogleCloud
class DatabasesController < Projects::GoogleCloud::BaseController
+ before_action :validate_gcp_token!
+ before_action :validate_product, only: :new
+
def index
js_data = {
configurationUrl: project_google_cloud_configuration_path(project),
deploymentsUrl: project_google_cloud_deployments_path(project),
- databasesUrl: project_google_cloud_databases_path(project)
+ databasesUrl: project_google_cloud_databases_path(project),
+ cloudsqlPostgresUrl: new_project_google_cloud_database_path(project, :postgres),
+ cloudsqlMysqlUrl: new_project_google_cloud_database_path(project, :mysql),
+ cloudsqlSqlserverUrl: new_project_google_cloud_database_path(project, :sqlserver),
+ cloudsqlInstances: ::GoogleCloud::GetCloudsqlInstancesService.new(project).execute,
+ emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg')
}
@js_data = js_data.to_json
- track_event('databases#index', 'success', js_data)
+
+ track_event(:render_page)
+ end
+
+ def new
+ product = permitted_params[:product].to_sym
+
+ @title = title(product)
+
+ @js_data = {
+ gcpProjects: gcp_projects,
+ refs: refs,
+ cancelPath: project_google_cloud_databases_path(project),
+ formTitle: form_title(product),
+ formDescription: description(product),
+ databaseVersions: Projects::GoogleCloud::CloudsqlHelper::VERSIONS[product],
+ tiers: Projects::GoogleCloud::CloudsqlHelper::TIERS
+ }.to_json
+
+ track_event(:render_form)
+ render template: 'projects/google_cloud/databases/cloudsql_form', formats: :html
+ end
+
+ def create
+ enable_response = ::GoogleCloud::EnableCloudsqlService
+ .new(project, current_user, enable_service_params)
+ .execute
+
+ if enable_response[:status] == :error
+ track_event(:error_enable_cloudsql_services)
+ flash[:error] = error_message(enable_response[:message])
+ else
+ permitted_params = params.permit(:gcp_project, :ref, :database_version, :tier)
+ create_response = ::GoogleCloud::CreateCloudsqlInstanceService
+ .new(project, current_user, create_service_params(permitted_params))
+ .execute
+
+ if create_response[:status] == :error
+ track_event(:error_create_cloudsql_instance)
+ flash[:warning] = error_message(create_response[:message])
+ else
+ track_event(:create_cloudsql_instance, permitted_params.to_s)
+ flash[:notice] = success_message
+ end
+ end
+
+ redirect_to project_google_cloud_databases_path(project)
+ end
+
+ private
+
+ def enable_service_params
+ { google_oauth2_token: token_in_session }
+ end
+
+ def create_service_params(permitted_params)
+ {
+ google_oauth2_token: token_in_session,
+ gcp_project_id: permitted_params[:gcp_project],
+ environment_name: permitted_params[:ref],
+ database_version: permitted_params[:database_version],
+ tier: permitted_params[:tier]
+ }
+ end
+
+ def error_message(message)
+ format(s_("CloudSeed|Google Cloud Error - %{message}"), message: message)
+ end
+
+ def success_message
+ s_('CloudSeed|Cloud SQL instance creation request successful. Expected resolution time is ~5 minutes.')
+ end
+
+ def validate_product
+ not_found unless permitted_params[:product].in?(%w[postgres mysql sqlserver])
+ end
+
+ def permitted_params
+ params.permit(:product)
+ end
+
+ def title(product)
+ case product
+ when :postgres
+ s_('CloudSeed|Create Postgres Instance')
+ when :mysql
+ s_('CloudSeed|Create MySQL Instance')
+ else
+ s_('CloudSeed|Create MySQL Instance')
+ end
+ end
+
+ def form_title(product)
+ case product
+ when :postgres
+ s_('CloudSeed|Cloud SQL for Postgres')
+ when :mysql
+ s_('CloudSeed|Cloud SQL for MySQL')
+ else
+ s_('CloudSeed|Cloud SQL for SQL Server')
+ end
+ end
+
+ def description(product)
+ case product
+ when :postgres
+ s_('CloudSeed|Cloud SQL instances are fully managed, relational PostgreSQL databases. '\
+ 'Google handles replication, patch management, and database management '\
+ 'to ensure availability and performance.')
+ when :mysql
+ s_('Cloud SQL instances are fully managed, relational MySQL databases. '\
+ 'Google handles replication, patch management, and database management '\
+ 'to ensure availability and performance.')
+ else
+ s_('Cloud SQL instances are fully managed, relational SQL Server databases. ' \
+ 'Google handles replication, patch management, and database management ' \
+ 'to ensure availability and performance.')
+ end
end
end
end
diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb
index 1ac4697a63f..f6cc8d5eafb 100644
--- a/app/controllers/projects/google_cloud/deployments_controller.rb
+++ b/app/controllers/projects/google_cloud/deployments_controller.rb
@@ -12,7 +12,7 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base
enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project)
}
@js_data = js_data.to_json
- track_event('deployments#index', 'success', js_data)
+ track_event(:render_page)
end
def cloud_run
@@ -21,7 +21,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', 'error_enable_cloud_run', enable_cloud_run_response)
+ track_event(:error_enable_services)
flash[:error] = enable_cloud_run_response[:message]
redirect_to project_google_cloud_deployments_path(project)
else
@@ -30,17 +30,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', 'error_generate_pipeline', generate_pipeline_response)
+ track_event(:error_generate_cloudrun_pipeline)
flash[:error] = 'Failed to generate pipeline'
redirect_to project_google_cloud_deployments_path(project)
else
cloud_run_mr_params = cloud_run_mr_params(generate_pipeline_response[:branch_name])
- track_event('deployments#cloud_run', 'success', cloud_run_mr_params)
+ track_event(:generate_cloudrun_pipeline)
redirect_to project_new_merge_request_path(project, merge_request: cloud_run_mr_params)
end
end
- rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e
- track_event('deployments#cloud_run', 'error_gcp', e)
+ rescue Google::Apis::Error => e
+ track_event(:error_google_api)
flash[:warning] = _('Google Cloud Error - %{error}') % { error: e }
redirect_to project_google_cloud_deployments_path(project)
end
diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
index 39f33624804..2f0bc05030f 100644
--- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb
+++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
@@ -15,13 +15,13 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC
cancelPath: project_google_cloud_configuration_path(project)
}
@js_data = js_data.to_json
- track_event('gcp_regions#index', 'success', js_data)
+ track_event(:render_form)
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', 'success', response)
+ GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region])
+ track_event(:configure_region)
redirect_to project_google_cloud_configuration_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
index 1a9a2daf4f2..dbf91806722 100644
--- a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb
+++ b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb
@@ -9,10 +9,10 @@ class Projects::GoogleCloud::RevokeOauthController < Projects::GoogleCloud::Base
if response.success?
redirect_message = { notice: s_('GoogleCloud|Google OAuth2 token revocation requested') }
- track_event('revoke_oauth#create', 'success', response.to_json)
+ track_event(:revoke_oauth)
else
redirect_message = { alert: s_('GoogleCloud|Google OAuth2 token revocation request failed') }
- track_event('revoke_oauth#create', 'error', response.to_json)
+ track_event(:error)
end
session.delete(GoogleApi::CloudPlatform::Client.session_key_for_token)
diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb
index 7f25054177e..89d624764df 100644
--- a/app/controllers/projects/google_cloud/service_accounts_controller.rb
+++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb
@@ -5,7 +5,7 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
def index
if gcp_projects.empty?
- track_event('service_accounts#index', 'error_form', 'no_gcp_projects')
+ track_event(:error_no_gcp_projects)
flash[:warning] = _('No Google Cloud projects - You need at least one Google Cloud project')
redirect_to project_google_cloud_configuration_path(project)
else
@@ -16,10 +16,10 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
}
@js_data = js_data.to_json
- track_event('service_accounts#index', 'success', js_data)
+ track_event(:render_form)
end
- rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e
- track_event('service_accounts#index', 'error_gcp', e)
+ rescue Google::Apis::Error => e
+ track_event(:error_google_api)
flash[:warning] = _('Google Cloud Error - %{error}') % { error: e }
redirect_to project_google_cloud_configuration_path(project)
end
@@ -35,10 +35,10 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
environment_name: permitted_params[:ref]
).execute
- track_event('service_accounts#create', 'success', response)
+ track_event(:create_service_account)
redirect_to project_google_cloud_configuration_path(project), notice: response.message
- rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e
- track_event('service_accounts#create', 'error_gcp', e)
+ rescue Google::Apis::Error => e
+ track_event(:error_google_api)
flash[:warning] = _('Google Cloud Error - %{error}') % { error: e }
redirect_to project_google_cloud_configuration_path(project)
end
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index 63309cce1e5..47557133ac8 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -2,14 +2,18 @@
class Projects::GraphsController < Projects::ApplicationController
include ExtractsPath
- include RedisTracking
+ include ProductAnalyticsTracking
# Authorize
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_read_repository_graphs!
- track_redis_hll_event :charts, name: 'p_analytics_repo'
+ track_custom_event :charts,
+ name: 'p_analytics_repo',
+ action: 'perform_analytics_usage_action',
+ label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
+ destinations: %i[redis_hll snowplow]
feature_category :source_code_management, [:show, :commits, :languages, :charts]
urgency :low, [:show]
@@ -102,6 +106,14 @@ class Projects::GraphsController < Projects::ApplicationController
render json: @log.to_json
end
+
+ def tracking_namespace_source
+ project.namespace
+ end
+
+ def tracking_project_source
+ project
+ end
end
Projects::GraphsController.prepend_mod
diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb
index 0ca3d71f728..3ab4c34737d 100644
--- a/app/controllers/projects/hook_logs_controller.rb
+++ b/app/controllers/projects/hook_logs_controller.rb
@@ -1,40 +1,19 @@
# frozen_string_literal: true
class Projects::HookLogsController < Projects::ApplicationController
- include ::Integrations::HooksExecution
-
before_action :authorize_admin_project!
- before_action :hook, only: [:show, :retry]
- before_action :hook_log, only: [:show, :retry]
-
- respond_to :html
+ include WebHooks::HookLogActions
layout 'project_settings'
- feature_category :integrations
- urgency :low, [:retry]
-
- def show
- end
-
- def retry
- execute_hook
- redirect_to edit_project_hook_path(@project, @hook)
- end
-
private
- def execute_hook
- result = hook.execute(hook_log.request_data, hook_log.trigger)
- set_hook_execution_notice(result)
- end
-
def hook
@hook ||= @project.hooks.find(params[:hook_id])
end
- def hook_log
- @hook_log ||= hook.web_hook_logs.find(params[:id])
+ def after_retry_redirect_path
+ edit_project_hook_path(@project, hook)
end
end
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 50f388324f1..22b6bf6faf0 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Projects::HooksController < Projects::ApplicationController
- include ::Integrations::HooksExecution
+ include ::WebHooks::HookActions
# Authorize
before_action :authorize_admin_project!
@@ -35,7 +35,7 @@ class Projects::HooksController < Projects::ApplicationController
end
def hook_logs
- @hook_logs ||= hook.web_hook_logs.recent.page(params[:page])
+ @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]).without_count
end
def trigger_values
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 36b52533e78..cbf0c756e1e 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -11,6 +11,7 @@ class Projects::IncidentsController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:work_items_hierarchy, @project)
+ push_frontend_feature_flag(:remove_user_attributes_projects, @project)
end
feature_category :incident_management
@@ -28,7 +29,7 @@ class Projects::IncidentsController < Projects::ApplicationController
.inc_relations_for_view
.iid_in(params[:id])
.without_order
- .first
+ .take # rubocop:disable CodeReuse/ActiveRecord
end
end
diff --git a/app/controllers/projects/integrations/shimos_controller.rb b/app/controllers/projects/integrations/shimos_controller.rb
index 827dbb8f3f9..6c8313d0805 100644
--- a/app/controllers/projects/integrations/shimos_controller.rb
+++ b/app/controllers/projects/integrations/shimos_controller.rb
@@ -12,7 +12,7 @@ module Projects
private
def ensure_renderable
- render_404 unless Feature.enabled?(:shimo_integration, project) && project.has_shimo? && project.shimo_integration&.render?
+ render_404 unless project.has_shimo? && project.shimo_integration&.render?
end
end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index d19db2b11ab..800a7df2566 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -42,6 +42,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:incident_timeline, project)
+ push_frontend_feature_flag(:remove_user_attributes_projects, project)
end
before_action only: [:index, :show] do
@@ -53,6 +54,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:realtime_labels, project)
push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:work_items_hierarchy, project)
+ push_frontend_feature_flag(:epic_widget_edit_confirmation, project)
push_force_frontend_feature_flag(:work_items_create_from_markdown, project&.work_items_create_from_markdown_feature_flag_enabled?)
end
@@ -63,19 +65,19 @@ class Projects::IssuesController < Projects::ApplicationController
alias_method :designs, :show
feature_category :team_planning, [
- :index, :calendar, :show, :new, :create, :edit, :update,
- :destroy, :move, :reorder, :designs, :toggle_subscription,
- :discussions, :bulk_update, :realtime_changes,
- :toggle_award_emoji, :mark_as_spam, :related_branches,
- :can_create_branch, :create_merge_request
- ]
+ :index, :calendar, :show, :new, :create, :edit, :update,
+ :destroy, :move, :reorder, :designs, :toggle_subscription,
+ :discussions, :bulk_update, :realtime_changes,
+ :toggle_award_emoji, :mark_as_spam, :related_branches,
+ :can_create_branch, :create_merge_request
+ ]
urgency :low, [
- :index, :calendar, :show, :new, :create, :edit, :update,
- :destroy, :move, :reorder, :designs, :toggle_subscription,
- :discussions, :bulk_update, :realtime_changes,
- :toggle_award_emoji, :mark_as_spam, :related_branches,
- :can_create_branch, :create_merge_request
- ]
+ :index, :calendar, :show, :new, :create, :edit, :update,
+ :destroy, :move, :reorder, :designs, :toggle_subscription,
+ :discussions, :bulk_update, :realtime_changes,
+ :toggle_award_emoji, :mark_as_spam, :related_branches,
+ :can_create_branch, :create_merge_request
+ ]
feature_category :service_desk, [:service_desk]
urgency :low, [:service_desk]
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 7878ace5015..557ac566733 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -20,6 +20,9 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
before_action :push_job_log_jump_to_failures, only: [:show]
before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase]
+ before_action do
+ push_frontend_feature_flag(:graphql_job_app, project, type: :development)
+ end
layout 'project'
@@ -120,11 +123,13 @@ class Projects::JobsController < Projects::ApplicationController
end
def erase
- if @build.erase(erased_by: current_user)
+ service_response = Ci::BuildEraseService.new(@build, current_user).execute
+
+ if service_response.success?
redirect_to project_job_path(project, @build),
notice: _("Job has been successfully erased!")
else
- respond_422
+ head service_response.http_status
end
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 279fd4c457e..a68c2ffa06d 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -29,6 +29,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
render_diffs
end
+ # rubocop: disable Metrics/AbcSize
def diffs_batch
diff_options_hash = diff_options
diff_options_hash[:paths] = params[:paths] if params[:paths]
@@ -61,21 +62,11 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
options[:allow_tree_conflicts]
]
- if Feature.enabled?(:etag_merge_request_diff_batches, @merge_request.project)
- return unless stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs])
- end
+ return unless stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs])
- if diff_options_hash[:paths].blank?
- render_cached(
- diffs,
- with: PaginatedDiffSerializer.new(current_user: current_user),
- cache_context: -> (_) { [Digest::SHA256.hexdigest(cache_context.to_s)] },
- **options
- )
- else
- render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
- end
+ render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
end
+ # rubocop: enable Metrics/AbcSize
def diffs_metadata
diffs = @compare.diffs(diff_options)
diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb
index ff6b6bfaf27..74bb3ad1a63 100644
--- a/app/controllers/projects/merge_requests/drafts_controller.rb
+++ b/app/controllers/projects/merge_requests/drafts_controller.rb
@@ -49,8 +49,24 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
def publish
result = DraftNotes::PublishService.new(merge_request, current_user).execute(draft_note(allow_nil: true))
- if Feature.enabled?(:mr_review_submit_comment, @project) && create_note_params[:note]
- Notes::CreateService.new(@project, current_user, create_note_params).execute
+ if Feature.enabled?(:mr_review_submit_comment, @project)
+ if create_note_params[:note]
+ ::Notes::CreateService.new(@project, current_user, create_note_params).execute
+
+ merge_request_activity_counter.track_submit_review_comment(user: current_user)
+ end
+
+ if Gitlab::Utils.to_boolean(approve_params[:approve])
+ unless merge_request.approved_by?(current_user)
+ success = ::MergeRequests::ApprovalService.new(project: @project, current_user: current_user, params: approve_params).execute(merge_request)
+
+ unless success
+ return render json: { message: _('An error occurred while approving, please try again.') }, status: :internal_server_error
+ end
+ end
+
+ merge_request_activity_counter.track_submit_review_approve(user: current_user)
+ end
end
if result[:status] == :success
@@ -115,6 +131,10 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
end
end
+ def approve_params
+ params.permit(:approve)
+ end
+
def prepare_notes_for_rendering(notes)
return [] unless notes
@@ -147,4 +167,10 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
def authorize_create_note!
access_denied! unless can?(current_user, :create_note, merge_request)
end
+
+ def merge_request_activity_counter
+ Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter
+ end
end
+
+Projects::MergeRequests::DraftsController.prepend_mod
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 870c57fd6f3..5a212e9a152 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -45,6 +45,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:paginated_mr_discussions, project)
push_frontend_feature_flag(:mr_review_submit_comment, project)
push_frontend_feature_flag(:mr_experience_survey, project)
+ push_frontend_feature_flag(:remove_user_attributes_projects, @project)
end
before_action do
@@ -56,11 +57,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
after_action :log_merge_request_show, only: [:show]
feature_category :code_review, [
- :assign_related_issues, :bulk_update, :cancel_auto_merge,
- :commit_change_content, :commits, :context_commits, :destroy,
- :discussions, :edit, :index, :merge, :rebase, :remove_wip,
- :show, :toggle_award_emoji, :toggle_subscription, :update
- ]
+ :assign_related_issues, :bulk_update, :cancel_auto_merge,
+ :commit_change_content, :commits, :context_commits, :destroy,
+ :discussions, :edit, :index, :merge, :rebase, :remove_wip,
+ :show, :toggle_award_emoji, :toggle_subscription, :update
+ ]
feature_category :code_testing, [:test_reports, :coverage_reports]
feature_category :code_quality, [:codequality_reports, :codequality_mr_diff_reports]
@@ -219,7 +220,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def context_commits
# 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
+ commits = ContextCommitsFinder.new(project, @merge_request, {
+ search: params[:search],
+ author: params[:author],
+ committed_before: convert_date_to_epoch(params[:committed_before]),
+ committed_after: convert_date_to_epoch(params[:committed_after]),
+ limit: params[:limit]
+ }).execute
render json: CommitEntity.represent(commits, { type: :full, request: merge_request })
end
@@ -552,6 +559,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
diffs_metadata_project_json_merge_request_path(project, merge_request, 'json', params)
end
+
+ def convert_date_to_epoch(date)
+ Date.strptime(date, "%Y-%m-%d")&.to_time&.to_i if date
+ rescue Date::Error, TypeError
+ end
end
Projects::MergeRequestsController.prepend_mod_with('Projects::MergeRequestsController')
diff --git a/app/controllers/projects/packages/package_files_controller.rb b/app/controllers/projects/packages/package_files_controller.rb
index 32aadb4fcf4..1aa91ee1189 100644
--- a/app/controllers/projects/packages/package_files_controller.rb
+++ b/app/controllers/projects/packages/package_files_controller.rb
@@ -11,6 +11,7 @@ module Projects
def download
package_file = project.package_files.find(params[:id])
+ package_file.package.touch_last_downloaded_at
send_upload(package_file.file, attachment: package_file.file_name)
end
end
diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb
index 8ac370b1bd4..d77cf095a4f 100644
--- a/app/controllers/projects/pipelines/tests_controller.rb
+++ b/app/controllers/projects/pipelines/tests_controller.rb
@@ -51,7 +51,8 @@ module Projects
def test_suite
suite = builds.sum do |build|
- build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
+ test_report = build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
+ test_report.get_suite(build.test_suite_name)
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 b2aa1d9f4ca..2a8f7171f9c 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -3,6 +3,7 @@
class Projects::PipelinesController < Projects::ApplicationController
include ::Gitlab::Utils::StrongMemoize
include RedisTracking
+ include ProductAnalyticsTracking
include ProjectStatsRefreshConflictsGuard
include ZuoraCSP
@@ -25,6 +26,7 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:pipeline_tabs_vue, @project)
+ push_frontend_feature_flag(:run_pipeline_graphql, @project)
end
# Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596
@@ -32,8 +34,11 @@ class Projects::PipelinesController < Projects::ApplicationController
around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
- # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/345074
- track_redis_hll_event :charts, name: 'p_analytics_pipelines'
+ track_custom_event :charts,
+ name: 'p_analytics_pipelines',
+ action: 'perform_analytics_usage_action',
+ label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
+ destinations: %i[redis_hll snowplow]
track_redis_hll_event :charts, name: 'p_analytics_ci_cd_pipelines', if: -> { should_track_ci_cd_pipelines? }
track_redis_hll_event :charts, name: 'p_analytics_ci_cd_deployment_frequency', if: -> { should_track_ci_cd_deployment_frequency? }
@@ -46,10 +51,10 @@ class Projects::PipelinesController < Projects::ApplicationController
POLLING_INTERVAL = 10_000
feature_category :continuous_integration, [
- :charts, :show, :config_variables, :stage, :cancel, :retry,
- :builds, :dag, :failures, :status,
- :index, :create, :new, :destroy
- ]
+ :charts, :show, :config_variables, :stage, :cancel, :retry,
+ :builds, :dag, :failures, :status,
+ :index, :create, :new, :destroy
+ ]
feature_category :code_testing, [:test_report]
feature_category :build_artifacts, [:downloadable_artifacts]
@@ -371,6 +376,14 @@ class Projects::PipelinesController < Projects::ApplicationController
def should_track_ci_cd_change_failure_rate?
params[:chart] == 'change-failure-rate'
end
+
+ def tracking_namespace_source
+ project.namespace
+ end
+
+ def tracking_project_source
+ project
+ end
end
Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController')
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index ba9576795ec..ee12b85b3a4 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -15,7 +15,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
def update
- if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params)
+ if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success?
redirect_to project_runner_path(@project, @runner), notice: _('Runner was successfully updated.')
else
render 'edit'
@@ -31,7 +31,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
def resume
- if Ci::Runners::UpdateRunnerService.new(@runner).update(active: true)
+ if Ci::Runners::UpdateRunnerService.new(@runner).execute(active: true).success?
redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.')
else
redirect_to project_runners_path(@project), alert: _('Runner was not updated.')
@@ -39,7 +39,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
def pause
- if Ci::Runners::UpdateRunnerService.new(@runner).update(active: false)
+ if Ci::Runners::UpdateRunnerService.new(@runner).execute(active: false).success?
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/settings/integration_hook_logs_controller.rb b/app/controllers/projects/settings/integration_hook_logs_controller.rb
index 1e42fbce4c4..3a921ecad0d 100644
--- a/app/controllers/projects/settings/integration_hook_logs_controller.rb
+++ b/app/controllers/projects/settings/integration_hook_logs_controller.rb
@@ -7,13 +7,13 @@ module Projects
before_action :integration, only: [:show, :retry]
- def retry
- execute_hook
- redirect_to edit_project_settings_integration_path(@project, @integration)
- end
-
private
+ override :after_retry_redirect_path
+ def after_retry_redirect_path
+ edit_project_settings_integration_path(@project, @integration)
+ end
+
def integration
@integration ||= @project.find_or_initialize_integration(params[:integration_id])
end
diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb
index 03ef434456f..2bbcd9fe20c 100644
--- a/app/controllers/projects/settings/integrations_controller.rb
+++ b/app/controllers/projects/settings/integrations_controller.rb
@@ -11,7 +11,7 @@ module Projects
before_action :integration, only: [:edit, :update, :test]
before_action :default_integration, only: [:edit, :update]
before_action :web_hook_logs, only: [:edit, :update]
- before_action -> { check_rate_limit!(:project_testing_integration, scope: [@project, current_user]) }, only: :test
+ before_action -> { check_test_rate_limit! }, only: :test
respond_to :html
@@ -124,7 +124,7 @@ module Projects
def web_hook_logs
return unless integration.try(:service_hook).present?
- @web_hook_logs ||= integration.service_hook.web_hook_logs.recent.page(params[:page])
+ @web_hook_logs ||= integration.service_hook.web_hook_logs.recent.page(params[:page]).without_count
end
def ensure_integration_enabled
@@ -140,6 +140,15 @@ module Projects
def use_inherited_settings?(attributes)
default_integration && attributes[:inherit_from_id] == default_integration.id.to_s
end
+
+ def check_test_rate_limit!
+ check_rate_limit!(:project_testing_integration, scope: [@project, current_user]) do
+ render json: {
+ error: true,
+ message: _('This endpoint has been requested too many times. Try again later.')
+ }, status: :ok
+ end
+ end
end
end
end
diff --git a/app/controllers/projects/settings/merge_requests_controller.rb b/app/controllers/projects/settings/merge_requests_controller.rb
new file mode 100644
index 00000000000..93e10695767
--- /dev/null
+++ b/app/controllers/projects/settings/merge_requests_controller.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Projects
+ module Settings
+ class MergeRequestsController < Projects::ApplicationController
+ layout 'project_settings'
+
+ before_action :merge_requests_enabled?
+ before_action :present_project, only: [:edit]
+ before_action :authorize_admin_project!
+
+ feature_category :code_review
+
+ def update
+ result = ::Projects::UpdateService.new(@project, current_user, project_params).execute
+
+ if result[:status] == :success
+ flash[:notice] = format(_("Project '%{project_name}' was successfully updated."), project_name: @project.name)
+ redirect_to project_settings_merge_requests_path(@project)
+ else
+ # Refresh the repo in case anything changed
+ @repository = @project.repository.reset
+
+ flash[:alert] = result[:message]
+ @project.reset
+ render 'show'
+ end
+ end
+
+ private
+
+ def merge_requests_enabled?
+ render_404 unless @project.merge_requests_enabled?
+ end
+
+ def project_params
+ params.require(:project)
+ .permit(project_params_attributes)
+ end
+
+ def project_setting_attributes
+ %i[
+ squash_option
+ allow_editing_commit_messages
+ mr_default_target_self
+ ]
+ end
+
+ def project_params_attributes
+ [
+ :allow_merge_on_skipped_pipeline,
+ :resolve_outdated_diff_discussions,
+ :only_allow_merge_if_all_discussions_are_resolved,
+ :only_allow_merge_if_pipeline_succeeds,
+ :printing_merge_request_link_enabled,
+ :remove_source_branch_after_merge,
+ :merge_method,
+ :merge_commit_template_or_default,
+ :squash_commit_template_or_default,
+ :suggestion_commit_message
+ ] + [project_setting_attributes: project_setting_attributes]
+ end
+ end
+ end
+end
+
+Projects::Settings::MergeRequestsController.prepend_mod_with('Projects::Settings::MergeRequestsController')
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index a178b8f7aa3..43c6451577a 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -34,13 +34,13 @@ module Projects
def create_deploy_token
result = Projects::DeployTokens::CreateService.new(@project, current_user, deploy_token_params).execute
- @new_deploy_token = result[:deploy_token]
if result[:status] == :success
+ @created_deploy_token = result[:deploy_token]
respond_to do |format|
format.json do
# IMPORTANT: It's a security risk to expose the token value more than just once here!
- json = API::Entities::DeployTokenWithToken.represent(@new_deploy_token).as_json
+ json = API::Entities::DeployTokenWithToken.represent(@created_deploy_token).as_json
render json: json, status: result[:http_status]
end
format.html do
@@ -49,6 +49,7 @@ module Projects
end
end
else
+ @new_deploy_token = result[:deploy_token]
respond_to do |format|
format.json { render json: { message: result[:message] }, status: result[:http_status] }
format.html do
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index a364668ea5f..8f4987a07f6 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -35,14 +35,4 @@ class Projects::UploadsController < Projects::ApplicationController
Project.find_by_full_path("#{namespace}/#{id}")
end
-
- # Overrides ApplicationController#build_canonical_path since there are
- # multiple routes that match project uploads:
- # https://gitlab.com/gitlab-org/gitlab/issues/196396
- def build_canonical_path(project)
- return super unless action_name == 'show'
- return super unless params[:secret] && params[:filename]
-
- show_namespace_project_uploads_url(project.namespace.to_param, project.to_param, params[:secret], params[:filename])
- end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 8a6bcb4b3fc..5ceedbc1e01 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -12,6 +12,8 @@ class ProjectsController < Projects::ApplicationController
include SourcegraphDecorator
include PlanningHierarchy
+ REFS_LIMIT = 100
+
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
@@ -54,9 +56,9 @@ class ProjectsController < Projects::ApplicationController
layout :determine_layout
feature_category :projects, [
- :index, :show, :new, :create, :edit, :update, :transfer,
- :destroy, :archive, :unarchive, :toggle_star, :activity
- ]
+ :index, :show, :new, :create, :edit, :update, :transfer,
+ :destroy, :archive, :unarchive, :toggle_star, :activity
+ ]
feature_category :source_code_management, [:remove_fork, :housekeeping, :refs]
feature_category :team_planning, [:preview_markdown, :new_issuable_address]
@@ -309,6 +311,8 @@ class ProjectsController < Projects::ApplicationController
find_tags = true
find_commits = true
+ use_gitaly_pagination = Feature.enabled?(:use_gitaly_pagination_for_refs, @project)
+
unless find_refs.nil?
find_branches = find_refs.include?('branches')
find_tags = find_refs.include?('tags')
@@ -318,13 +322,21 @@ class ProjectsController < Projects::ApplicationController
options = {}
if find_branches
- branches = BranchesFinder.new(@repository, refs_params).execute.take(100).map(&:name)
+ branches = BranchesFinder.new(@repository, refs_params.merge(per_page: REFS_LIMIT))
+ .execute(gitaly_pagination: use_gitaly_pagination)
+ .take(REFS_LIMIT)
+ .map(&:name)
+
options['Branches'] = branches
end
if find_tags && @repository.tag_count.nonzero?
- tags = TagsFinder.new(@repository, refs_params).execute
- options['Tags'] = tags.take(100).map(&:name)
+ tags = TagsFinder.new(@repository, refs_params.merge(per_page: REFS_LIMIT))
+ .execute(gitaly_pagination: use_gitaly_pagination)
+ .take(REFS_LIMIT)
+ .map(&:name)
+
+ options['Tags'] = tags
end
# If reference is commit id - we should add it to branch/tag selectbox
@@ -430,6 +442,7 @@ class ProjectsController < Projects::ApplicationController
if Feature.enabled?(:split_operations_visibility_permissions, project)
%i[
environments_access_level feature_flags_access_level releases_access_level
+ monitor_access_level
]
else
%i[operations_access_level]
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 33d2c482795..0bd266bb490 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -32,6 +32,7 @@ class RegistrationsController < Devise::RegistrationsController
def create
set_user_state
+ token = set_custom_confirmation_token
super do |new_user|
accept_pending_invitations if new_user.persisted?
@@ -39,6 +40,7 @@ class RegistrationsController < Devise::RegistrationsController
persist_accepted_terms_if_required(new_user)
set_role_required(new_user)
track_experiment_event(new_user)
+ send_custom_confirmation_instructions(new_user, token)
if pending_approval?
NotificationService.new.new_instance_access_request(new_user)
@@ -118,8 +120,10 @@ class RegistrationsController < Devise::RegistrationsController
def after_inactive_sign_up_path_for(resource)
Gitlab::AppLogger.info(user_created_message)
return new_user_session_path(anchor: 'login-pane') if resource.blocked_pending_approval?
+ return dashboard_projects_path if Feature.enabled?(:soft_email_confirmation)
+ return identity_verification_redirect_path if custom_confirmation_enabled?(resource)
- Feature.enabled?(:soft_email_confirmation) ? dashboard_projects_path : users_almost_there_path(email: resource.email)
+ users_almost_there_path(email: resource.email)
end
private
@@ -236,6 +240,22 @@ class RegistrationsController < Devise::RegistrationsController
# signing up and becoming users
experiment(:logged_out_marketing_header, actor: new_user).track(:signed_up) if new_user.persisted?
end
+
+ def identity_verification_redirect_path
+ # overridden by EE module
+ end
+
+ def custom_confirmation_enabled?(resource)
+ # overridden by EE module
+ end
+
+ def set_custom_confirmation_token
+ # overridden by EE module
+ end
+
+ def send_custom_confirmation_instructions(user, token)
+ # overridden by EE module
+ end
end
RegistrationsController.prepend_mod_with('RegistrationsController')
diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb
index fbf5d82a45b..a5ca17db113 100644
--- a/app/controllers/repositories/git_http_client_controller.rb
+++ b/app/controllers/repositories/git_http_client_controller.rb
@@ -3,7 +3,7 @@
module Repositories
class GitHttpClientController < Repositories::ApplicationController
include ActionController::HttpAuthentication::Basic
- include KerberosSpnegoHelper
+ include KerberosHelper
include Gitlab::Utils::StrongMemoize
attr_reader :authentication_result, :redirected_path
@@ -49,7 +49,7 @@ module Repositories
if handle_basic_authentication(login, password)
return # Allow access
end
- elsif allow_kerberos_spnego_auth? && spnego_provided?
+ elsif allow_kerberos_auth? && spnego_provided?
kerberos_user = find_kerberos_user
if kerberos_user
@@ -91,7 +91,7 @@ module Repositories
def send_challenges
challenges = []
challenges << 'Basic realm="GitLab"' if allow_basic_auth?
- challenges << spnego_challenge if allow_kerberos_spnego_auth?
+ challenges << spnego_challenge if allow_kerberos_auth?
headers['Www-Authenticate'] = challenges.join("\n") if challenges.any?
end
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index c3c6a51239d..144ec4c0de9 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -83,7 +83,7 @@ module Repositories
return if Gitlab::Database.read_only?
return unless repo_type.project?
- OnboardingProgressService.async(project.namespace_id).execute(action: :git_pull)
+ Onboarding::ProgressService.async(project.namespace_id).execute(action: :git_pull)
return if Feature.enabled?(:disable_git_http_fetch_writes)
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 5843e13c7cd..9f87ad6aaf6 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -57,7 +57,25 @@ class SearchController < ApplicationController
@search_highlight = @search_service.search_highlight
end
+ Gitlab::Metrics::GlobalSearchSlis.record_apdex(
+ elapsed: @global_search_duration_s,
+ search_type: @search_type,
+ search_level: @search_level,
+ search_scope: @scope
+ )
+
increment_search_counters
+ ensure
+ if @search_type
+ # If we raise an error somewhere in the @global_search_duration_s benchmark block, we will end up here
+ # with a 200 status code, but an empty @global_search_duration_s.
+ Gitlab::Metrics::GlobalSearchSlis.record_error_rate(
+ error: @global_search_duration_s.nil? || (status < 200 || status >= 400),
+ search_type: @search_type,
+ search_level: @search_level,
+ search_scope: @scope
+ )
+ end
end
def count
diff --git a/app/experiments/combined_registration_experiment.rb b/app/experiments/combined_registration_experiment.rb
deleted file mode 100644
index 38295cec0d3..00000000000
--- a/app/experiments/combined_registration_experiment.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-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
-
- def redirect_path
- run
- end
-end
diff --git a/app/finders/context_commits_finder.rb b/app/finders/context_commits_finder.rb
index d623854ada4..4a45817cc61 100644
--- a/app/finders/context_commits_finder.rb
+++ b/app/finders/context_commits_finder.rb
@@ -5,8 +5,10 @@ class ContextCommitsFinder
@project = project
@merge_request = merge_request
@search = params[:search]
+ @author = params[:author]
+ @committed_before = params[:committed_before]
+ @committed_after = params[:committed_after]
@limit = (params[:limit] || 40).to_i
- @offset = (params[:offset] || 0).to_i
end
def execute
@@ -16,13 +18,13 @@ class ContextCommitsFinder
private
- attr_reader :project, :merge_request, :search, :limit, :offset
+ attr_reader :project, :merge_request, :search, :author, :committed_before, :committed_after, :limit
def init_collection
if search.present?
search_commits
else
- project.repository.commits(merge_request.target_branch, { limit: limit, offset: offset })
+ project.repository.commits(merge_request.target_branch, { limit: limit })
end
end
@@ -41,7 +43,8 @@ class ContextCommitsFinder
commits = [commit_by_sha] if commit_by_sha
end
else
- commits = project.repository.find_commits_by_message(search, merge_request.target_branch, nil, 20)
+ commits = project.repository.list_commits_by(search, merge_request.target_branch,
+ author: author, before: committed_before, after: committed_after, limit: limit)
end
commits
diff --git a/app/finders/crm/organizations_finder.rb b/app/finders/crm/organizations_finder.rb
index 5a8ab148ef3..69f72235c71 100644
--- a/app/finders/crm/organizations_finder.rb
+++ b/app/finders/crm/organizations_finder.rb
@@ -16,6 +16,11 @@ module Crm
attr_reader :params, :current_user
+ def self.counts_by_state(current_user, params = {})
+ params = params.merge(sort: nil)
+ new(current_user, params).execute.counts_by_state
+ end
+
def initialize(current_user, params = {})
@current_user = current_user
@params = params
@@ -28,11 +33,20 @@ module Crm
organizations = by_ids(organizations)
organizations = by_search(organizations)
organizations = by_state(organizations)
- organizations.sort_by_name
+ sort_organizations(organizations)
end
private
+ def sort_organizations(organizations)
+ return organizations.sort_by_name unless @params.key?(:sort)
+ return organizations if @params[:sort].nil?
+
+ field = @params[:sort][:field]
+ direction = @params[:sort][:direction]
+ organizations.sort_by_field(field, direction)
+ end
+
def root_group
strong_memoize(:root_group) do
group = params[:group]&.root_ancestor
diff --git a/app/finders/database/batched_background_migrations_finder.rb b/app/finders/database/batched_background_migrations_finder.rb
new file mode 100644
index 00000000000..866acd47238
--- /dev/null
+++ b/app/finders/database/batched_background_migrations_finder.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Database
+ class BatchedBackgroundMigrationsFinder
+ RETURNED_MIGRATIONS = 20
+
+ def initialize(connection:)
+ @connection = connection
+ end
+
+ def execute
+ batched_migration_class.ordered_by_created_at_desc.for_gitlab_schema(schema).limit(RETURNED_MIGRATIONS)
+ end
+
+ private
+
+ attr_accessor :connection
+
+ def batched_migration_class
+ Gitlab::Database::BackgroundMigration::BatchedMigration
+ end
+
+ def schema
+ Gitlab::Database.gitlab_schemas_for_connection(connection)
+ end
+ end
+end
diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb
index 04b82ee04ec..5b2139cb941 100644
--- a/app/finders/deployments_finder.rb
+++ b/app/finders/deployments_finder.rb
@@ -9,8 +9,8 @@
# updated_before: DateTime
# finished_after: DateTime
# finished_before: DateTime
-# environment: String
-# status: String (see Deployment.statuses)
+# environment: String (name) or Integer (ID)
+# status: String or Array<String> (see Deployment.statuses)
# order_by: String (see ALLOWED_SORT_VALUES constant)
# sort: String (asc | desc)
class DeploymentsFinder
@@ -33,6 +33,7 @@ class DeploymentsFinder
def initialize(params = {})
@params = params
+ @params[:status] = Array(@params[:status]).map(&:to_s) if @params[:status]
validate!
end
@@ -68,16 +69,25 @@ class DeploymentsFinder
raise error if raise_for_inefficient_updated_at_query?
end
- if (filter_by_finished_at? && !order_by_finished_at?) || (!filter_by_finished_at? && order_by_finished_at?)
- raise InefficientQueryError, '`finished_at` filter and `finished_at` sorting must be paired'
+ if filter_by_finished_at? && !order_by_finished_at?
+ raise InefficientQueryError, '`finished_at` filter requires `finished_at` sort.'
+ end
+
+ if order_by_finished_at? && !(filter_by_finished_at? || filter_by_finished_statuses?)
+ raise InefficientQueryError,
+ '`finished_at` sort requires `finished_at` filter or a filter with at least one of the finished statuses.'
end
if filter_by_finished_at? && !filter_by_successful_deployment?
raise InefficientQueryError, '`finished_at` filter must be combined with `success` status filter.'
end
- if params[:environment].present? && !params[:project].present?
- raise InefficientQueryError, '`environment` filter must be combined with `project` scope.'
+ if filter_by_environment_name? && !params[:project].present?
+ raise InefficientQueryError, '`environment` name filter must be combined with `project` scope.'
+ end
+
+ if filter_by_finished_statuses? && filter_by_upcoming_statuses?
+ raise InefficientQueryError, 'finished statuses and upcoming statuses must be separately queried.'
end
end
@@ -86,6 +96,8 @@ class DeploymentsFinder
params[:project].deployments
elsif params[:group].present?
::Deployment.for_projects(params[:group].all_projects)
+ elsif filter_by_environment_id?
+ ::Deployment.for_environment(params[:environment])
else
::Deployment.none
end
@@ -112,7 +124,7 @@ class DeploymentsFinder
end
def by_environment(items)
- if params[:project].present? && params[:environment].present?
+ if params[:project].present? && filter_by_environment_name?
items.for_environment_name(params[:project], params[:environment])
else
items
@@ -122,7 +134,7 @@ class DeploymentsFinder
def by_status(items)
return items unless params[:status].present?
- unless Deployment.statuses.key?(params[:status])
+ unless Deployment.statuses.keys.intersection(params[:status]) == params[:status]
raise ArgumentError, "The deployment status #{params[:status]} is invalid"
end
@@ -165,7 +177,23 @@ class DeploymentsFinder
end
def filter_by_successful_deployment?
- params[:status].to_s == 'success'
+ params[:status].present? && params[:status].count == 1 && params[:status].first.to_s == 'success'
+ end
+
+ def filter_by_finished_statuses?
+ params[:status].present? && Deployment::FINISHED_STATUSES.map(&:to_s).intersection(params[:status]).any?
+ end
+
+ def filter_by_upcoming_statuses?
+ params[:status].present? && Deployment::UPCOMING_STATUSES.map(&:to_s).intersection(params[:status]).any?
+ end
+
+ def filter_by_environment_name?
+ params[:environment].present? && params[:environment].is_a?(String)
+ end
+
+ def filter_by_environment_id?
+ params[:environment].present? && params[:environment].is_a?(Integer)
end
def order_by_updated_at?
@@ -183,6 +211,7 @@ class DeploymentsFinder
environment: [],
deployable: {
job_artifacts: [],
+ user: [],
pipeline: {
project: {
route: [],
diff --git a/app/finders/environments/environments_finder.rb b/app/finders/environments/environments_finder.rb
index 46c49f096c6..f2dcba04349 100644
--- a/app/finders/environments/environments_finder.rb
+++ b/app/finders/environments/environments_finder.rb
@@ -14,6 +14,7 @@ module Environments
def execute
environments = project.environments
+ environments = by_type(environments)
environments = by_name(environments)
environments = by_search(environments)
environments = by_ids(environments)
@@ -24,6 +25,12 @@ module Environments
private
+ def by_type(environments)
+ return environments unless params[:type].present?
+
+ environments.for_type(params[:type])
+ end
+
def by_name(environments)
if params[:name].present?
environments.for_name(params[:name])
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index 048e25046da..4688d561897 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -47,7 +47,7 @@ class GroupMembersFinder < UnionFinder
related_groups << Group.by_id(group.id) if include_relations&.include?(:direct)
related_groups << group.ancestors if include_relations&.include?(:inherited)
related_groups << group.descendants if include_relations&.include?(:descendants)
- related_groups << group.shared_with_groups.public_or_visible_to_user(user) if include_relations&.include?(:shared_from_groups)
+ related_groups << Group.shared_into_ancestors(group).public_or_visible_to_user(user) if include_relations&.include?(:shared_from_groups)
find_union(related_groups, Group)
end
diff --git a/app/finders/groups/accepting_group_transfers_finder.rb b/app/finders/groups/accepting_group_transfers_finder.rb
new file mode 100644
index 00000000000..df67f940d20
--- /dev/null
+++ b/app/finders/groups/accepting_group_transfers_finder.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Groups
+ class AcceptingGroupTransfersFinder < Base
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(current_user, group_to_be_transferred, params = {})
+ @current_user = current_user
+ @group_to_be_transferred = group_to_be_transferred
+ @params = params
+ end
+
+ def execute
+ return Group.none unless can_transfer_group?
+
+ items = if Feature.enabled?(:include_groups_from_group_shares_in_group_transfer_locations)
+ find_all_groups
+ else
+ find_groups
+ end
+
+ items = by_search(items)
+
+ sort(items)
+ end
+
+ private
+
+ attr_reader :current_user, :group_to_be_transferred, :params
+
+ def find_groups
+ GroupsFinder.new( # rubocop: disable CodeReuse/Finder
+ current_user,
+ min_access_level: Gitlab::Access::OWNER,
+ exclude_group_ids: exclude_groups
+ ).execute.without_order
+ end
+
+ def find_all_groups
+ ::Namespace.from_union(
+ [
+ find_groups,
+ groups_originating_from_group_shares_with_owner_access
+ ]
+ )
+ end
+
+ def groups_originating_from_group_shares_with_owner_access
+ GroupGroupLink
+ .with_owner_access
+ .groups_accessible_via(
+ current_user.owned_groups.select(:id)
+ ).id_not_in(exclude_groups)
+ end
+
+ def exclude_groups
+ strong_memoize(:exclude_groups) do
+ exclude_groups = group_to_be_transferred.self_and_descendants.pluck_primary_key
+ exclude_groups << group_to_be_transferred.parent_id if group_to_be_transferred.parent_id
+
+ exclude_groups
+ end
+ end
+
+ def can_transfer_group?
+ Ability.allowed?(current_user, :admin_group, group_to_be_transferred)
+ end
+ end
+end
diff --git a/app/finders/groups/accepting_project_transfers_finder.rb b/app/finders/groups/accepting_project_transfers_finder.rb
index 09d3c430641..a3f58a78eca 100644
--- a/app/finders/groups/accepting_project_transfers_finder.rb
+++ b/app/finders/groups/accepting_project_transfers_finder.rb
@@ -7,10 +7,6 @@ module Groups
end
def execute
- if Feature.disabled?(:include_groups_from_group_shares_in_project_transfer_locations)
- return current_user.manageable_groups
- end
-
groups_accepting_project_transfers =
[
current_user.manageable_groups,
diff --git a/app/finders/groups/base.rb b/app/finders/groups/base.rb
new file mode 100644
index 00000000000..d7f56b1a7a6
--- /dev/null
+++ b/app/finders/groups/base.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Groups
+ class Base
+ private
+
+ def sort(items)
+ items.order(Group.arel_table[:path].asc, Group.arel_table[:id].asc) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def by_search(items)
+ return items if params[:search].blank?
+
+ items.search(params[:search], include_parents: true)
+ end
+ end
+end
diff --git a/app/finders/groups/user_groups_finder.rb b/app/finders/groups/user_groups_finder.rb
index bda8b7cc1e0..b58c1323b1f 100644
--- a/app/finders/groups/user_groups_finder.rb
+++ b/app/finders/groups/user_groups_finder.rb
@@ -13,7 +13,7 @@
#
# Initially created to filter user groups and descendants where the user can create projects
module Groups
- class UserGroupsFinder
+ class UserGroupsFinder < Base
def initialize(current_user, target_user, params = {})
@current_user = current_user
@target_user = target_user
@@ -34,16 +34,6 @@ module Groups
attr_reader :current_user, :target_user, :params
- def sort(items)
- items.order(Group.arel_table[:path].asc, Group.arel_table[:id].asc) # rubocop: disable CodeReuse/ActiveRecord
- end
-
- def by_search(items)
- return items if params[:search].blank?
-
- items.search(params[:search], include_parents: true)
- end
-
def by_permission_scope
if permission_scope_create_projects?
target_user.manageable_groups(include_groups_with_developer_maintainer_access: true)
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index 9a8bc74f435..61d79885001 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -15,6 +15,7 @@
# exclude_group_ids: array of integers
# include_parent_descendants: boolean (defaults to false) - includes descendant groups when
# filtering by parent. The parent param must be present.
+# include_ancestors: boolean (defaults to true)
#
# Users with full private access can see all groups. The `owned` and `parent`
# params can be used to restrict the groups that are returned.
@@ -52,15 +53,7 @@ class GroupsFinder < UnionFinder
return [Group.all] if current_user&.can_read_all_resources? && all_available?
groups = []
-
- if current_user
- if Feature.enabled?(:use_traversal_ids_groups_finder, current_user)
- groups << current_user.authorized_groups.self_and_ancestors
- groups << current_user.groups.self_and_descendants
- else
- groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects
- end
- end
+ groups = get_groups_for_user if current_user
groups << Group.unscoped.public_to_user(current_user) if include_public_groups?
groups << Group.none if groups.empty?
@@ -136,4 +129,29 @@ class GroupsFinder < UnionFinder
def min_access_level?
current_user && params[:min_access_level].present?
end
+
+ def include_ancestors?
+ params.fetch(:include_ancestors, true)
+ end
+
+ def get_groups_for_user
+ groups = []
+
+ if Feature.enabled?(:use_traversal_ids_groups_finder, current_user)
+ groups << if include_ancestors?
+ current_user.authorized_groups.self_and_ancestors
+ else
+ current_user.authorized_groups
+ end
+
+ groups << current_user.groups.self_and_descendants
+ elsif include_ancestors?
+ groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects
+ else
+ groups << current_user.authorized_groups
+ groups << Gitlab::ObjectHierarchy.new(groups_for_descendants).base_and_descendants
+ end
+
+ groups
+ end
end
diff --git a/app/finders/incident_management/timeline_events_finder.rb b/app/finders/incident_management/timeline_events_finder.rb
index 09de46bb79f..aaf3133236a 100644
--- a/app/finders/incident_management/timeline_events_finder.rb
+++ b/app/finders/incident_management/timeline_events_finder.rb
@@ -31,7 +31,7 @@ module IncidentManagement
end
def sort(collection)
- collection.order_occurred_at_asc
+ collection.order_occurred_at_asc_id_asc
end
end
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 1088d53c9a0..9f331d381aa 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -46,8 +46,7 @@ class IssuableFinder
requires_cross_project_access unless: -> { params.project? }
- FULL_TEXT_SEARCH_TERM_PATTERN = '[\u0000-\u218F]*'
- FULL_TEXT_SEARCH_TERM_REGEX = /\A#{FULL_TEXT_SEARCH_TERM_PATTERN}\z/.freeze
+ 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
@@ -59,19 +58,19 @@ class IssuableFinder
class << self
def scalar_params
@scalar_params ||= %i[
- assignee_id
- assignee_username
- author_id
- author_username
- crm_contact_id
- crm_organization_id
- label_name
- milestone_title
- release_tag
- my_reaction_emoji
- search
- in
- ]
+ assignee_id
+ assignee_username
+ author_id
+ author_username
+ crm_contact_id
+ crm_organization_id
+ label_name
+ milestone_title
+ release_tag
+ my_reaction_emoji
+ search
+ in
+ ]
end
def array_params
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 663dda73a6a..9f96abcd4e5 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -60,10 +60,10 @@ class IssuesFinder < IssuableFinder
# count of issues assigned to the user for the header bar.
return issues.all if current_user && assignee_filter.includes_user?(current_user)
- return issues.where('issues.confidential IS NOT TRUE') if params.user_cannot_see_confidential_issues?
+ return issues.public_only if params.user_cannot_see_confidential_issues?
issues.where('
- issues.confidential IS NOT TRUE
+ issues.confidential = FALSE
OR (issues.confidential = TRUE
AND (issues.author_id = :user_id
OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
diff --git a/app/finders/merge_requests/by_approvals_finder.rb b/app/finders/merge_requests/by_approvals_finder.rb
index 94f13468327..8b2e9aa8df1 100644
--- a/app/finders/merge_requests/by_approvals_finder.rb
+++ b/app/finders/merge_requests/by_approvals_finder.rb
@@ -71,9 +71,7 @@ module MergeRequests
#
# @param [ActiveRecord::Relation] items the activerecord relation
def with_any_approvals(items)
- items.select_from_union([
- items.with_approvals
- ])
+ items.select_from_union([items.with_approvals])
end
# Merge requests approved by given usernames
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 06feefb9059..ffa912afd1e 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -30,6 +30,8 @@
# updated_before: datetime
#
class MergeRequestsFinder < IssuableFinder
+ extend ::Gitlab::Utils::Override
+
include MergedAtFilter
def self.scalar_params
@@ -44,8 +46,7 @@ class MergeRequestsFinder < IssuableFinder
:reviewer_id,
:reviewer_username,
:target_branch,
- :wip,
- :attention
+ :wip
]
end
@@ -70,7 +71,6 @@ class MergeRequestsFinder < IssuableFinder
items = by_approvals(items)
items = by_deployments(items)
items = by_reviewer(items)
- items = by_attention(items)
by_source_project_id(items)
end
@@ -84,6 +84,16 @@ class MergeRequestsFinder < IssuableFinder
private
+ override :sort
+ def sort(items)
+ items = super(items)
+
+ return items unless use_grouping_columns?
+
+ grouping_columns = klass.grouping_columns(params[:sort])
+ items.group(grouping_columns) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
def by_commit(items)
return items unless params[:commit_sha].presence
@@ -220,18 +230,18 @@ class MergeRequestsFinder < IssuableFinder
end
end
- def by_attention(items)
- return items unless params.attention?
-
- items.attention(params.attention)
- end
-
def parse_datetime(input)
# To work around http://www.ruby-lang.org/en/news/2021/11/15/date-parsing-method-regexp-dos-cve-2021-41817/
DateTime.parse(input.byteslice(0, 128)) if input
rescue Date::Error
nil
end
+
+ def use_grouping_columns?
+ return false unless params[:sort].present?
+
+ params[:approved_by_usernames].present? || params[:approved_by_ids].present?
+ end
end
MergeRequestsFinder.prepend_mod_with('MergeRequestsFinder')
diff --git a/app/finders/merge_requests_finder/params.rb b/app/finders/merge_requests_finder/params.rb
index 1c6a425c8af..e44e96054d3 100644
--- a/app/finders/merge_requests_finder/params.rb
+++ b/app/finders/merge_requests_finder/params.rb
@@ -21,11 +21,5 @@ class MergeRequestsFinder
end
end
end
-
- def attention
- strong_memoize(:attention) do
- User.find_by_username(params[:attention])
- end
- end
end
end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 6b8dcd61d29..6bfe730ebc9 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -119,9 +119,9 @@ class ProjectsFinder < UnionFinder
# This is an optimization - surprisingly PostgreSQL does not optimize
# for this.
#
- # If the default visiblity level and desired visiblity level filter cancels
+ # If the default visibility level and desired visibility level filter cancels
# each other out, don't use the SQL clause for visibility level in
- # `Project.public_or_visible_to_user`. In fact, this then becames equivalent
+ # `Project.public_or_visible_to_user`. In fact, this then becomes equivalent
# to just authorized projects for the user.
#
# E.g.
diff --git a/app/finders/user_groups_counter.rb b/app/finders/user_groups_counter.rb
index 7dbc8502be2..e8e552510cd 100644
--- a/app/finders/user_groups_counter.rb
+++ b/app/finders/user_groups_counter.rb
@@ -8,9 +8,9 @@ class UserGroupsCounter
def execute
Namespace.unscoped do
Namespace.from_union([
- groups,
- project_groups
- ]).group(:user_id).count # rubocop: disable CodeReuse/ActiveRecord
+ groups,
+ project_groups
+ ]).group(:user_id).count # rubocop: disable CodeReuse/ActiveRecord
end
end
diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb
index b39875b83a9..8086d8c02a4 100644
--- a/app/graphql/graphql_triggers.rb
+++ b/app/graphql/graphql_triggers.rb
@@ -21,3 +21,5 @@ module GraphqlTriggers
GitlabSchema.subscriptions.trigger('issuableDatesUpdated', { issuable_id: issuable.to_gid }, issuable)
end
end
+
+GraphqlTriggers.prepend_mod
diff --git a/app/graphql/mutations/boards/issues/issue_move_list.rb b/app/graphql/mutations/boards/issues/issue_move_list.rb
index 14fe9714f99..e9cae80e5f9 100644
--- a/app/graphql/mutations/boards/issues/issue_move_list.rb
+++ b/app/graphql/mutations/boards/issues/issue_move_list.rb
@@ -38,10 +38,16 @@ module Mutations
required: false,
description: 'ID of issue that should be placed after the current issue.'
+ argument :position_in_list, GraphQL::Types::Int,
+ required: false,
+ description: "Position of issue within the board list. Positions start at 0. "\
+ "Use #{::Boards::Issues::MoveService::LIST_END_POSITION} to move to the end of the list."
+
def ready?(**args)
if move_arguments(args).blank?
raise Gitlab::Graphql::Errors::ArgumentError,
- 'At least one of the arguments fromListId, toListId, afterId or beforeId is required'
+ 'At least one of the arguments ' \
+ 'fromListId, toListId, positionInList, moveAfterId, or moveBeforeId is required'
end
if move_list_arguments(args).one?
@@ -49,6 +55,24 @@ module Mutations
'Both fromListId and toListId must be present'
end
+ if args[:position_in_list].present?
+ if move_list_arguments(args).empty?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'Both fromListId and toListId are required when positionInList is given'
+ end
+
+ if args[:move_before_id].present? || args[:move_after_id].present?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'positionInList is mutually exclusive with any of moveBeforeId or moveAfterId'
+ end
+
+ if args[:position_in_list] != ::Boards::Issues::MoveService::LIST_END_POSITION &&
+ args[:position_in_list] < 0
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ "positionInList must be >= 0 or #{::Boards::Issues::MoveService::LIST_END_POSITION}"
+ end
+ end
+
super
end
@@ -77,7 +101,7 @@ module Mutations
end
def move_arguments(args)
- args.slice(:from_list_id, :to_list_id, :move_after_id, :move_before_id)
+ args.slice(:from_list_id, :to_list_id, :position_in_list, :move_after_id, :move_before_id)
end
def error_for(result)
diff --git a/app/graphql/mutations/ci/job/artifacts_destroy.rb b/app/graphql/mutations/ci/job/artifacts_destroy.rb
new file mode 100644
index 00000000000..c27ab9c4d89
--- /dev/null
+++ b/app/graphql/mutations/ci/job/artifacts_destroy.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module Job
+ class ArtifactsDestroy < Base
+ graphql_name 'JobArtifactsDestroy'
+
+ authorize :destroy_artifacts
+
+ field :job,
+ Types::Ci::JobType,
+ null: true,
+ description: 'Job with artifacts to be deleted.'
+
+ field :destroyed_artifacts_count,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Number of artifacts deleted.'
+
+ def find_object(id: )
+ GlobalID::Locator.locate(id)
+ end
+
+ def resolve(id:)
+ job = authorized_find!(id: id)
+
+ result = ::Ci::JobArtifacts::DestroyBatchService.new(job.job_artifacts, pick_up_at: Time.current).execute
+ {
+ job: job,
+ destroyed_artifacts_count: result[:destroyed_artifacts_count],
+ errors: Array(result[:errors])
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/job_artifact/destroy.rb b/app/graphql/mutations/ci/job_artifact/destroy.rb
new file mode 100644
index 00000000000..47b3535d631
--- /dev/null
+++ b/app/graphql/mutations/ci/job_artifact/destroy.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module JobArtifact
+ class Destroy < BaseMutation
+ graphql_name 'ArtifactDestroy'
+
+ authorize :destroy_artifacts
+
+ ArtifactID = ::Types::GlobalIDType[::Ci::JobArtifact]
+
+ argument :id,
+ ArtifactID,
+ required: true,
+ description: 'ID of the artifact to delete.'
+
+ field :artifact,
+ Types::Ci::JobArtifactType,
+ null: true,
+ description: 'Deleted artifact.'
+
+ def find_object(id: )
+ GlobalID::Locator.locate(id)
+ end
+
+ def resolve(id:)
+ artifact = authorized_find!(id: id)
+
+ if artifact.destroy
+ { errors: [] }
+ else
+ { errors: artifact.errors.full_messages }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/runner/bulk_delete.rb b/app/graphql/mutations/ci/runner/bulk_delete.rb
index 4c1c2967799..4265099d28e 100644
--- a/app/graphql/mutations/ci/runner/bulk_delete.rb
+++ b/app/graphql/mutations/ci/runner/bulk_delete.rb
@@ -40,9 +40,7 @@ module Mutations
private
def model_ids_of(ids)
- ids.map do |gid|
- gid.model_id.to_i
- end.compact
+ ids.filter_map { |gid| gid.model_id.to_i }
end
def find_all_runners_by_ids(ids)
diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb
index 1c6cf6989bf..f98138646be 100644
--- a/app/graphql/mutations/ci/runner/update.rb
+++ b/app/graphql/mutations/ci/runner/update.rb
@@ -48,8 +48,13 @@ module Mutations
description: 'Indicates the runner is able to run untagged jobs.'
argument :tag_list, [GraphQL::Types::String],
- required: false,
- description: 'Tags associated with the runner.'
+ required: false,
+ description: 'Tags associated with the runner.'
+
+ argument :associated_projects, [::Types::GlobalIDType[::Project]],
+ required: false,
+ description: 'Projects associated with the runner. Available only for project runners.',
+ prepare: -> (global_ids, ctx) { global_ids&.filter_map { |gid| gid.model_id.to_i } }
field :runner,
Types::Ci::RunnerType,
@@ -59,16 +64,47 @@ module Mutations
def resolve(id:, **runner_attrs)
runner = authorized_find!(id)
- unless ::Ci::Runners::UpdateRunnerService.new(runner).update(runner_attrs)
- return { runner: nil, errors: runner.errors.full_messages }
+ associated_projects_ids = runner_attrs.delete(:associated_projects)
+
+ response = { runner: runner, errors: [] }
+ ::Ci::Runner.transaction do
+ associate_runner_projects(response, runner, associated_projects_ids) if associated_projects_ids.present?
+ update_runner(response, runner, runner_attrs)
end
- { runner: runner, errors: [] }
+ response
end
def find_object(id)
GitlabSchema.find_by_gid(id)
end
+
+ private
+
+ def associate_runner_projects(response, runner, associated_project_ids)
+ unless runner.project_type?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ "associatedProjects must not be specified for '#{runner.runner_type}' scope"
+ end
+
+ result = ::Ci::Runners::SetRunnerAssociatedProjectsService.new(
+ runner: runner,
+ current_user: current_user,
+ project_ids: associated_project_ids
+ ).execute
+ return if result.success?
+
+ response[:errors] = result.errors
+ raise ActiveRecord::Rollback
+ end
+
+ def update_runner(response, runner, attrs)
+ result = ::Ci::Runners::UpdateRunnerService.new(runner).execute(attrs)
+ return if result.success?
+
+ response[:errors] = result.errors
+ raise ActiveRecord::Rollback
+ end
end
end
end
diff --git a/app/graphql/mutations/custom_emoji/create.rb b/app/graphql/mutations/custom_emoji/create.rb
index 269ea6c9999..535ff44a7fd 100644
--- a/app/graphql/mutations/custom_emoji/create.rb
+++ b/app/graphql/mutations/custom_emoji/create.rb
@@ -28,6 +28,10 @@ module Mutations
description: 'Location of the emoji file.'
def resolve(group_path:, **args)
+ if Feature.disabled?(:custom_emoji)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Custom emoji feature is disabled'
+ end
+
group = authorized_find!(group_path: group_path)
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37911#note_444682238
args[:external] = true
diff --git a/app/graphql/mutations/custom_emoji/destroy.rb b/app/graphql/mutations/custom_emoji/destroy.rb
index 863b8152cc7..64e3f2ed7d3 100644
--- a/app/graphql/mutations/custom_emoji/destroy.rb
+++ b/app/graphql/mutations/custom_emoji/destroy.rb
@@ -17,6 +17,10 @@ module Mutations
description: 'Global ID of the custom emoji to destroy.'
def resolve(id:)
+ if Feature.disabled?(:custom_emoji)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Custom emoji feature is disabled'
+ end
+
custom_emoji = authorized_find!(id: id)
custom_emoji.destroy!
diff --git a/app/graphql/mutations/dependency_proxy/group_settings/update.rb b/app/graphql/mutations/dependency_proxy/group_settings/update.rb
index 65c919db3c3..6be07edd883 100644
--- a/app/graphql/mutations/dependency_proxy/group_settings/update.rb
+++ b/app/graphql/mutations/dependency_proxy/group_settings/update.rb
@@ -8,6 +8,11 @@ module Mutations
include Mutations::ResolvesGroup
+ description 'These settings can be adjusted by the group Owner or Maintainer. However, in GitLab 16.0, we ' \
+ 'will be limiting this to the Owner role. ' \
+ '[GitLab-#364441](https://gitlab.com/gitlab-org/gitlab/-/issues/364441) proposes making ' \
+ 'this change to match the permissions level in the user interface.'
+
authorize :admin_dependency_proxy
argument :group_path,
diff --git a/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb b/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb
index 31ae29d896b..bb1da9278ff 100644
--- a/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb
+++ b/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb
@@ -6,6 +6,8 @@ module Mutations
class PromoteFromNote < Base
graphql_name 'TimelineEventPromoteFromNote'
+ include NotesHelper
+
argument :note_id, Types::GlobalIDType[::Note],
required: true,
description: 'Note ID from which the timeline event promoted.'
@@ -20,7 +22,7 @@ module Mutations
incident,
current_user,
promoted_from_note: note,
- note: note.note,
+ note: build_note_string(note),
occurred_at: note.created_at,
editable: true
).execute
@@ -38,6 +40,11 @@ module Mutations
super
end
+ def build_note_string(note)
+ commented = _('commented')
+ "@#{note.author.username} [#{commented}](#{noteable_note_url(note)}): '#{note.note}'"
+ end
+
def raise_noteable_not_incident!
raise_resource_not_available_error! 'Note does not belong to an incident'
end
diff --git a/app/graphql/mutations/releases/create.rb b/app/graphql/mutations/releases/create.rb
index 70a0e71c869..ba1fa8d446c 100644
--- a/app/graphql/mutations/releases/create.rb
+++ b/app/graphql/mutations/releases/create.rb
@@ -32,7 +32,7 @@ module Mutations
argument :released_at, Types::TimeType,
required: false,
- description: 'Date and time for the release. Defaults to the current date and time.'
+ description: 'Date and time for the release. Defaults to the current time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). Only provide this field if creating an upcoming or historical release.'
argument :milestones, [GraphQL::Types::String],
required: false,
diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb
index fe0ad6df65b..20913a9e7da 100644
--- a/app/graphql/mutations/todos/restore_many.rb
+++ b/app/graphql/mutations/todos/restore_many.rb
@@ -32,9 +32,7 @@ module Mutations
private
def model_ids_of(ids)
- ids.map do |gid|
- gid.model_id.to_i
- end.compact
+ ids.filter_map { |gid| gid.model_id.to_i }
end
def raise_too_many_todos_requested_error
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/graphql/queries/repository/blob_info.query.graphql
index 45a7793e559..fd463436ed4 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/graphql/queries/repository/blob_info.query.graphql
@@ -1,5 +1,3 @@
-#import "ee_else_ce/repository/queries/path_locks.fragment.graphql"
-
query getBlobInfo(
$projectPath: ID!
$filePath: String!
@@ -7,17 +5,15 @@ query getBlobInfo(
$shouldFetchRawText: Boolean!
) {
project(fullPath: $projectPath) {
- userPermissions {
- pushCode
- downloadCode
- createMergeRequestIn
- forkProject
- }
- ...ProjectPathLocksFragment
+ __typename
+ id
repository {
+ __typename
empty
blobs(paths: [$filePath], ref: $ref) {
+ __typename
nodes {
+ __typename
id
webPath
name
diff --git a/app/graphql/resolvers/ci/job_token_scope_resolver.rb b/app/graphql/resolvers/ci/job_token_scope_resolver.rb
index ca76a7b94fc..7c6aedad1d6 100644
--- a/app/graphql/resolvers/ci/job_token_scope_resolver.rb
+++ b/app/graphql/resolvers/ci/job_token_scope_resolver.rb
@@ -6,14 +6,12 @@ module Resolvers
include Gitlab::Graphql::Authorize::AuthorizeResource
authorize :admin_project
- description 'Container for resources that can be accessed by a CI job token from the current project. Null if job token scope setting is disabled.'
+ description 'Container for resources that can be accessed by a CI job token from the current project.'
type ::Types::Ci::JobTokenScopeType, null: true
def resolve
authorize!(object)
- return unless object.ci_job_token_scope_enabled?
-
::Ci::JobToken::Scope.new(object)
end
end
diff --git a/app/graphql/resolvers/ci/runner_jobs_resolver.rb b/app/graphql/resolvers/ci/runner_jobs_resolver.rb
index 2f6ca09d031..de00aadaea8 100644
--- a/app/graphql/resolvers/ci/runner_jobs_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_jobs_resolver.rb
@@ -9,6 +9,7 @@ module Resolvers
type ::Types::Ci::JobType.connection_type, null: true
authorize :read_builds
authorizes_object!
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
argument :statuses, [::Types::Ci::JobStatusEnum],
required: false,
@@ -16,15 +17,6 @@ module Resolvers
alias_method :runner, :object
- def ready?(**args)
- context[self.class] ||= { executions: 0 }
- context[self.class][:executions] += 1
-
- raise GraphQL::ExecutionError, "Jobs can be requested for only one runner at a time" if context[self.class][:executions] > 1
-
- super
- end
-
def resolve_with_lookahead(statuses: nil)
jobs = ::Ci::JobsFinder.new(current_user: current_user, runner: runner, params: { scope: statuses }).execute
diff --git a/app/graphql/resolvers/ci/runner_owner_project_resolver.rb b/app/graphql/resolvers/ci/runner_owner_project_resolver.rb
index 14b5f8f90eb..da8fab93619 100644
--- a/app/graphql/resolvers/ci/runner_owner_project_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_owner_project_resolver.rb
@@ -9,7 +9,7 @@ module Resolvers
alias_method :runner, :object
- def resolve_with_lookahead(**args)
+ def resolve_with_lookahead(**_args)
resolve_owner
end
@@ -19,6 +19,8 @@ module Resolvers
}
end
+ private
+
def filtered_preloads
selection = lookahead
@@ -27,8 +29,6 @@ module Resolvers
end
end
- private
-
def resolve_owner
return unless runner.project_type?
@@ -48,14 +48,13 @@ module Resolvers
.transform_values { |runner_projects| runner_projects.first.project_id }
project_ids = owner_project_id_by_runner_id.values.uniq
- all_preloads = unconditional_includes + filtered_preloads
- owner_relation = Project.all
- owner_relation = owner_relation.preload(*all_preloads) if all_preloads.any?
- projects = owner_relation.where(id: project_ids).index_by(&:id)
+ projects = Project.where(id: project_ids)
+ Preloaders::ProjectPolicyPreloader.new(projects, current_user).execute
+ projects_by_id = projects.index_by(&:id)
runner_ids.each do |runner_id|
owner_project_id = owner_project_id_by_runner_id[runner_id]
- loader.call(runner_id, projects[owner_project_id])
+ loader.call(runner_id, projects_by_id[owner_project_id])
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/graphql/resolvers/ci/runner_projects_resolver.rb b/app/graphql/resolvers/ci/runner_projects_resolver.rb
new file mode 100644
index 00000000000..ca3b4ebb797
--- /dev/null
+++ b/app/graphql/resolvers/ci/runner_projects_resolver.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class RunnerProjectsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+ include LooksAhead
+ include ProjectSearchArguments
+
+ type Types::ProjectType.connection_type, null: true
+ authorize :read_runner
+ authorizes_object!
+
+ alias_method :runner, :object
+
+ argument :sort, GraphQL::Types::String,
+ required: false,
+ default_value: 'id_asc', # TODO: Remove in %16.0 and move :sort to ProjectSearchArguments, see https://gitlab.com/gitlab-org/gitlab/-/issues/372117
+ deprecated: {
+ reason: 'Default sort order will change in 16.0. ' \
+ 'Specify `"id_asc"` if query results\' order is important',
+ milestone: '15.4'
+ },
+ description: "Sort order of results. Format: '<field_name>_<sort_direction>', " \
+ "for example: 'id_desc' or 'name_asc'"
+
+ def resolve_with_lookahead(**args)
+ return unless runner.project_type?
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ BatchLoader::GraphQL.for(runner.id).batch(key: :runner_projects) do |runner_ids, loader|
+ plucked_runner_and_project_ids = ::Ci::RunnerProject
+ .select(:runner_id, :project_id)
+ .where(runner_id: runner_ids)
+ .pluck(:runner_id, :project_id)
+
+ project_ids = plucked_runner_and_project_ids.collect { |_runner_id, project_id| project_id }.uniq
+ projects = ProjectsFinder
+ .new(current_user: current_user,
+ params: project_finder_params(args),
+ project_ids_relation: project_ids)
+ .execute
+ Preloaders::ProjectPolicyPreloader.new(projects, current_user).execute
+ projects_by_id = projects.index_by(&:id)
+
+ # In plucked_runner_and_project_ids, first() represents the runner ID, and second() the project ID,
+ # so let's group the project IDs by runner ID
+ runner_project_ids_by_runner_id =
+ plucked_runner_and_project_ids
+ .group_by(&:first)
+ .transform_values { |values| values.map(&:second).filter_map { |project_id| projects_by_id[project_id] } }
+
+ runner_ids.each do |runner_id|
+ runner_projects = runner_project_ids_by_runner_id[runner_id] || []
+
+ loader.call(runner_id, runner_projects)
+ end
+ end
+ # rubocop:enable CodeReuse/ActiveRecord
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/test_suite_resolver.rb b/app/graphql/resolvers/ci/test_suite_resolver.rb
index f758e217b47..a2d3af9c664 100644
--- a/app/graphql/resolvers/ci/test_suite_resolver.rb
+++ b/app/graphql/resolvers/ci/test_suite_resolver.rb
@@ -28,7 +28,8 @@ module Resolvers
def load_test_suite_data(builds)
suite = builds.sum do |build|
- build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
+ test_report = build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
+ test_report.get_suite(build.test_suite_name)
end
Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, pipeline.project).load!
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
index fe213936f55..8295bd58388 100644
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
@@ -76,24 +76,11 @@ module IssueResolverArguments
end
def resolve_with_lookahead(**args)
- # The project could have been loaded in batch by `BatchLoader`.
- # At this point we need the `id` of the project to query for issues, so
- # make sure it's loaded and not `nil` before continuing.
- parent = object.respond_to?(:sync) ? object.sync : object
- return Issue.none if parent.nil?
-
- # Will need to be made group & namespace aware with
- # https://gitlab.com/gitlab-org/gitlab-foss/issues/54520
- args[:not] = args[:not].to_h if args[:not].present?
- args[:iids] ||= [args.delete(:iid)].compact if args[:iid]
- args[:attempt_project_search_optimizations] = true if args[:search].present?
+ return Issue.none if resource_parent.nil?
- prepare_assignee_username_params(args)
- prepare_release_tag_params(args)
+ finder = IssuesFinder.new(current_user, prepare_finder_params(args))
- finder = IssuesFinder.new(current_user, args)
-
- continue_issue_resolve(parent, finder, **args)
+ continue_issue_resolve(resource_parent, finder, **args)
end
def ready?(**args)
@@ -103,7 +90,6 @@ module IssueResolverArguments
params_not_mutually_exclusive(args, mutually_exclusive_milestone_args)
params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args)
params_not_mutually_exclusive(args, mutually_exclusive_release_tag_args)
- validate_anonymous_search_access! if args[:search].present?
super
end
@@ -128,6 +114,17 @@ module IssueResolverArguments
private
+ def prepare_finder_params(args)
+ params = super(args)
+ params[:iids] ||= [params.delete(:iid)].compact if params[:iid]
+ params[:attempt_project_search_optimizations] = true if params[:search].present?
+
+ prepare_assignee_username_params(params)
+ prepare_release_tag_params(params)
+
+ params
+ end
+
def prepare_release_tag_params(args)
release_tag_wildcard = args.delete(:release_tag_wildcard_id)
return if release_tag_wildcard.blank?
@@ -135,20 +132,13 @@ module IssueResolverArguments
args[:release_tag] ||= release_tag_wildcard
end
- def mutually_exclusive_release_tag_args
- [:release_tag, :release_tag_wildcard_id]
- end
-
def prepare_assignee_username_params(args)
args[:assignee_username] = args.delete(:assignee_usernames) if args[:assignee_usernames].present?
args[:not][:assignee_username] = args[:not].delete(:assignee_usernames) if args.dig(:not, :assignee_usernames).present?
end
- def params_not_mutually_exclusive(args, mutually_exclusive_args)
- if args.slice(*mutually_exclusive_args).compact.size > 1
- arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ')
- raise ::Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time."
- end
+ def mutually_exclusive_release_tag_args
+ [:release_tag, :release_tag_wildcard_id]
end
def mutually_exclusive_milestone_args
@@ -158,4 +148,20 @@ module IssueResolverArguments
def mutually_exclusive_assignee_username_args
[:assignee_usernames, :assignee_username]
end
+
+ def params_not_mutually_exclusive(args, mutually_exclusive_args)
+ if args.slice(*mutually_exclusive_args).compact.size > 1
+ arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ')
+ raise ::Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time."
+ end
+ end
+
+ def resource_parent
+ # The project could have been loaded in batch by `BatchLoader`.
+ # At this point we need the `id` of the project to query for issues, so
+ # make sure it's loaded and not `nil` before continuing.
+ strong_memoize(:resource_parent) do
+ object.respond_to?(:sync) ? object.sync : object
+ end
+ end
end
diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb
index 644b2a11460..b548dc1e175 100644
--- a/app/graphql/resolvers/concerns/looks_ahead.rb
+++ b/app/graphql/resolvers/concerns/looks_ahead.rb
@@ -33,10 +33,14 @@ module LooksAhead
end
def filtered_preloads
- selection = node_selection
+ nodes = node_selection
+
+ return [] unless nodes
+
+ selected_fields = nodes.selections.map(&:name)
preloads.each.flat_map do |name, requirements|
- selection&.selects?(name) ? requirements : []
+ selected_fields.include?(name) ? requirements : []
end
end
diff --git a/app/graphql/resolvers/concerns/project_search_arguments.rb b/app/graphql/resolvers/concerns/project_search_arguments.rb
new file mode 100644
index 00000000000..7e03963f412
--- /dev/null
+++ b/app/graphql/resolvers/concerns/project_search_arguments.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module ProjectSearchArguments
+ extend ActiveSupport::Concern
+
+ included do
+ argument :membership, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Return only projects that the current user is a member of.'
+
+ argument :search, GraphQL::Types::String,
+ required: false,
+ description: 'Search query, which can be for the project name, a path, or a description.'
+
+ argument :search_namespaces, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Include namespace in project search.'
+
+ argument :topics, type: [GraphQL::Types::String],
+ required: false,
+ description: 'Filter projects by topics.'
+ end
+
+ private
+
+ def project_finder_params(params)
+ {
+ without_deleted: true,
+ non_public: params[:membership],
+ search: params[:search],
+ search_namespaces: params[:search_namespaces],
+ sort: params[:sort],
+ topic: params[:topics]
+ }.compact
+ end
+end
diff --git a/app/graphql/resolvers/concerns/search_arguments.rb b/app/graphql/resolvers/concerns/search_arguments.rb
index 7f480f9d0b6..95c6dbf7497 100644
--- a/app/graphql/resolvers/concerns/search_arguments.rb
+++ b/app/graphql/resolvers/concerns/search_arguments.rb
@@ -7,12 +7,49 @@ module SearchArguments
argument :search, GraphQL::Types::String,
required: false,
description: 'Search query for title or description.'
+ argument :in, [Types::IssuableSearchableFieldEnum],
+ required: false,
+ description: <<~DESC
+ Specify the fields to perform the search in.
+ Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'
+ DESC
+ end
+
+ def ready?(**args)
+ validate_search_in_params!(args)
+ validate_anonymous_search_access!(args)
+
+ super
end
- def validate_anonymous_search_access!
+ private
+
+ def validate_anonymous_search_access!(args)
+ return unless args[:search].present?
return if current_user.present? || Feature.disabled?(:disable_anonymous_search, type: :ops)
raise ::Gitlab::Graphql::Errors::ArgumentError,
"User must be authenticated to include the `search` argument."
end
+
+ def validate_search_in_params!(args)
+ return unless args[:in].present? && args[:search].blank?
+
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ '`search` should be present when including the `in` argument'
+ end
+
+ def prepare_finder_params(args)
+ prepare_search_params(args)
+ end
+
+ def prepare_search_params(args)
+ return args unless args[:search].present?
+
+ parent_type = resource_parent.is_a?(Project) ? :project : :group
+ args[:"attempt_#{parent_type}_search_optimizations"] = true
+ args[:in] = args[:in].join(',') if args[:in].present?
+
+ args
+ end
end
diff --git a/app/graphql/resolvers/crm/organization_state_counts_resolver.rb b/app/graphql/resolvers/crm/organization_state_counts_resolver.rb
new file mode 100644
index 00000000000..c16a4bd24ea
--- /dev/null
+++ b/app/graphql/resolvers/crm/organization_state_counts_resolver.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Crm
+ class OrganizationStateCountsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ authorize :read_crm_organization
+ authorizes_object!
+
+ type Types::CustomerRelations::OrganizationStateCountsType, null: true
+
+ argument :search, GraphQL::Types::String,
+ required: false,
+ description: 'Search term to find organizations with.'
+
+ argument :state, Types::CustomerRelations::OrganizationStateEnum,
+ required: false,
+ description: 'State of the organizations to search for.'
+
+ def resolve(**args)
+ ::Crm::OrganizationsFinder.counts_by_state(context[:current_user], args.merge({ group: object }))
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/crm/organizations_resolver.rb b/app/graphql/resolvers/crm/organizations_resolver.rb
index ca0a908ee22..719834f406d 100644
--- a/app/graphql/resolvers/crm/organizations_resolver.rb
+++ b/app/graphql/resolvers/crm/organizations_resolver.rb
@@ -10,6 +10,11 @@ module Resolvers
type Types::CustomerRelations::OrganizationType, null: true
+ argument :sort, Types::CustomerRelations::OrganizationSortEnum,
+ description: 'Criteria to sort organizations by.',
+ required: false,
+ default_value: { field: 'name', direction: :asc }
+
argument :search, GraphQL::Types::String,
required: false,
description: 'Search term used to find organizations with.'
@@ -24,6 +29,7 @@ module Resolvers
def resolve(**args)
args[:ids] = resolve_ids(args.delete(:ids))
+ args.delete(:state) if args[:state] == :all
::Crm::OrganizationsFinder.new(current_user, { group: group }.merge(args)).execute
end
diff --git a/app/graphql/resolvers/deployment_resolver.rb b/app/graphql/resolvers/deployment_resolver.rb
new file mode 100644
index 00000000000..7d9ce0f023c
--- /dev/null
+++ b/app/graphql/resolvers/deployment_resolver.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class DeploymentResolver < BaseResolver
+ argument :iid,
+ GraphQL::Types::ID,
+ required: true,
+ description: 'Project-level internal ID of the Deployment.'
+
+ type Types::DeploymentType, null: true
+
+ alias_method :project, :object
+
+ def resolve(iid:)
+ return unless project.present? && project.is_a?(::Project)
+
+ Deployment.for_iid(project, iid)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/deployments_resolver.rb b/app/graphql/resolvers/deployments_resolver.rb
new file mode 100644
index 00000000000..341d23c2ccb
--- /dev/null
+++ b/app/graphql/resolvers/deployments_resolver.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class DeploymentsResolver < BaseResolver
+ argument :statuses, [Types::DeploymentStatusEnum],
+ description: 'Statuses of the deployments.',
+ required: false,
+ as: :status
+
+ argument :order_by, Types::DeploymentsOrderByInputType,
+ description: 'Order by a specified field.',
+ required: false
+
+ type Types::DeploymentType, null: true
+
+ alias_method :environment, :object
+
+ def resolve(**args)
+ return unless environment.present? && environment.is_a?(::Environment)
+
+ args = transform_args_for_finder(**args)
+
+ # GraphQL BatchLoader shouldn't be used here because pagination query will be inefficient
+ # that fetches thousands of rows before limiting and offsetting.
+ DeploymentsFinder.new(environment: environment.id, **args).execute
+ end
+
+ private
+
+ def transform_args_for_finder(**args)
+ if (order_by = args.delete(:order_by))
+ order_by = order_by.to_h.map { |k, v| { order_by: k.to_s, sort: v } }.first
+ args.merge!(order_by)
+ end
+
+ args
+ end
+ end
+end
diff --git a/app/graphql/resolvers/environments/last_deployment_resolver.rb b/app/graphql/resolvers/environments/last_deployment_resolver.rb
new file mode 100644
index 00000000000..76f80112673
--- /dev/null
+++ b/app/graphql/resolvers/environments/last_deployment_resolver.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Environments
+ class LastDeploymentResolver < BaseResolver
+ argument :status,
+ Types::DeploymentStatusEnum,
+ required: true,
+ description: 'Status of the Deployment.'
+
+ type Types::DeploymentType, null: true
+
+ def resolve(status:)
+ return unless object.present? && object.is_a?(::Environment)
+
+ validate!(status)
+
+ find_last_deployment(status)
+ end
+
+ private
+
+ def find_last_deployment(status)
+ BatchLoader::GraphQL.for(object).batch(key: status) do |environments, loader, args|
+ association_name = "last_#{args[:key]}_deployment".to_sym
+
+ Preloaders::Environments::DeploymentPreloader.new(environments)
+ .execute_with_union(association_name, {})
+
+ environments.each do |environment|
+ loader.call(environment, environment.public_send(association_name)) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+
+ def validate!(status)
+ unless Deployment::FINISHED_STATUSES.include?(status.to_sym) ||
+ Deployment::UPCOMING_STATUSES.include?(status.to_sym)
+ raise Gitlab::Graphql::Errors::ArgumentError, "\"#{status}\" status is not supported."
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb
index 934c1ba2738..f265e2183d0 100644
--- a/app/graphql/resolvers/environments_resolver.rb
+++ b/app/graphql/resolvers/environments_resolver.rb
@@ -21,8 +21,8 @@ module Resolvers
def resolve(**args)
return unless project.present?
- Environments::EnvironmentsFinder.new(project, context[:current_user], args).execute
- rescue Environments::EnvironmentsFinder::InvalidStatesError => e
+ ::Environments::EnvironmentsFinder.new(project, context[:current_user], args).execute
+ rescue ::Environments::EnvironmentsFinder::InvalidStatesError => e
raise Gitlab::Graphql::Errors::ArgumentError, e.message
end
end
diff --git a/app/graphql/resolvers/group_packages_resolver.rb b/app/graphql/resolvers/group_packages_resolver.rb
index b48e0b75190..e6a6abb39dd 100644
--- a/app/graphql/resolvers/group_packages_resolver.rb
+++ b/app/graphql/resolvers/group_packages_resolver.rb
@@ -5,6 +5,8 @@ module Resolvers
class GroupPackagesResolver < PackagesBaseResolver
# The GraphQL type is defined in the extended class
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
+
argument :sort, Types::Packages::PackageGroupSortEnum,
description: 'Sort packages by this criteria.',
required: false,
@@ -15,14 +17,6 @@ module Resolvers
project_path_asc: { order_by: 'project_path', sort: 'asc' }
}).freeze
- def ready?(**args)
- context[self.class] ||= { executions: 0 }
- context[self.class][:executions] += 1
- raise GraphQL::ExecutionError, "Packages can be requested only for one group at a time" if context[self.class][:executions] > 1
-
- super
- end
-
def resolve(sort:, **filters)
return unless packages_available?
diff --git a/app/graphql/resolvers/members_resolver.rb b/app/graphql/resolvers/members_resolver.rb
index 827db54134a..3d7894fdd6a 100644
--- a/app/graphql/resolvers/members_resolver.rb
+++ b/app/graphql/resolvers/members_resolver.rb
@@ -11,6 +11,10 @@ module Resolvers
required: false,
description: 'Search query.'
+ argument :sort, ::Types::MemberSortEnum,
+ required: false,
+ description: 'sort query.'
+
def resolve_with_lookahead(**args)
authorize!(object)
diff --git a/app/graphql/resolvers/package_details_resolver.rb b/app/graphql/resolvers/package_details_resolver.rb
index 705d3900cd2..b77c6b1112b 100644
--- a/app/graphql/resolvers/package_details_resolver.rb
+++ b/app/graphql/resolvers/package_details_resolver.rb
@@ -2,20 +2,14 @@
module Resolvers
class PackageDetailsResolver < BaseResolver
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
+
type ::Types::Packages::PackageDetailsType, null: true
argument :id, ::Types::GlobalIDType[::Packages::Package],
required: true,
description: 'Global ID of the package.'
- def ready?(**args)
- context[self.class] ||= { executions: 0 }
- context[self.class][:executions] += 1
- raise GraphQL::ExecutionError, "Package details can be requested only for one package at a time" if context[self.class][:executions] > 1
-
- super
- end
-
def resolve(id:)
GitlabSchema.find_by_gid(id)
end
diff --git a/app/graphql/resolvers/project_jobs_resolver.rb b/app/graphql/resolvers/project_jobs_resolver.rb
index b09158d475d..4d13a4a3fae 100644
--- a/app/graphql/resolvers/project_jobs_resolver.rb
+++ b/app/graphql/resolvers/project_jobs_resolver.rb
@@ -8,6 +8,7 @@ module Resolvers
type ::Types::Ci::JobType.connection_type, null: true
authorize :read_build
authorizes_object!
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
argument :statuses, [::Types::Ci::JobStatusEnum],
required: false,
@@ -15,15 +16,6 @@ module Resolvers
alias_method :project, :object
- def ready?(**args)
- context[self.class] ||= { executions: 0 }
- context[self.class][:executions] += 1
-
- raise GraphQL::ExecutionError, "Jobs can be requested for only one project at a time" if context[self.class][:executions] > 1
-
- super
- end
-
def resolve_with_lookahead(statuses: nil)
jobs = ::Ci::JobsFinder.new(current_user: current_user, project: project, params: { scope: statuses }).execute
diff --git a/app/graphql/resolvers/projects/branch_rules_resolver.rb b/app/graphql/resolvers/projects/branch_rules_resolver.rb
new file mode 100644
index 00000000000..6c8b416bcea
--- /dev/null
+++ b/app/graphql/resolvers/projects/branch_rules_resolver.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class BranchRulesResolver < BaseResolver
+ type Types::Projects::BranchRuleType.connection_type, null: false
+
+ alias_method :project, :object
+
+ def resolve(**args)
+ project.protected_branches
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb
index facf8ffe36f..4d1e1b867da 100644
--- a/app/graphql/resolvers/projects_resolver.rb
+++ b/app/graphql/resolvers/projects_resolver.rb
@@ -2,31 +2,18 @@
module Resolvers
class ProjectsResolver < BaseResolver
- type Types::ProjectType, null: true
-
- argument :membership, GraphQL::Types::Boolean,
- required: false,
- description: 'Limit projects that the current user is a member of.'
+ include ProjectSearchArguments
- argument :search, GraphQL::Types::String,
- required: false,
- description: 'Search query for project name, path, or description.'
+ type Types::ProjectType, null: true
argument :ids, [GraphQL::Types::ID],
required: false,
description: 'Filter projects by IDs.'
- argument :search_namespaces, GraphQL::Types::Boolean,
- required: false,
- description: 'Include namespace in project search.'
-
argument :sort, GraphQL::Types::String,
required: false,
- description: 'Sort order of results.'
-
- argument :topics, type: [GraphQL::Types::String],
- required: false,
- description: 'Filters projects by topics.'
+ description: "Sort order of results. Format: '<field_name>_<sort_direction>', " \
+ "for example: 'id_desc' or 'name_asc'"
def resolve(**args)
ProjectsFinder
@@ -36,17 +23,6 @@ module Resolvers
private
- def project_finder_params(params)
- {
- without_deleted: true,
- non_public: params[:membership],
- search: params[:search],
- search_namespaces: params[:search_namespaces],
- sort: params[:sort],
- topic: params[:topics]
- }.compact
- end
-
def parse_gids(gids)
gids&.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::Project).model_id }
end
diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb
index 055984db3cb..9c7931a4edb 100644
--- a/app/graphql/resolvers/work_items_resolver.rb
+++ b/app/graphql/resolvers/work_items_resolver.rb
@@ -26,27 +26,31 @@ module Resolvers
required: false
def resolve_with_lookahead(**args)
- # The project could have been loaded in batch by `BatchLoader`.
- # At this point we need the `id` of the project to query for issues, so
- # make sure it's loaded and not `nil` before continuing.
- parent = object.respond_to?(:sync) ? object.sync : object
- return WorkItem.none if parent.nil? || !parent.work_items_feature_flag_enabled?
+ return WorkItem.none if resource_parent.nil? || !resource_parent.work_items_feature_flag_enabled?
- args[:iids] ||= [args.delete(:iid)].compact if args[:iid]
- args[:attempt_project_search_optimizations] = true if args[:search].present?
+ finder = ::WorkItems::WorkItemsFinder.new(current_user, prepare_finder_params(args))
- finder = ::WorkItems::WorkItemsFinder.new(current_user, args)
-
- Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all { |q| apply_lookahead(q) }
+ Gitlab::Graphql::Loaders::IssuableLoader.new(resource_parent, finder).batching_find_all { |q| apply_lookahead(q) }
end
- def ready?(**args)
- validate_anonymous_search_access! if args[:search].present?
+ private
- super
+ def preloads
+ {
+ last_edited_by: :last_edited_by
+ }
end
- private
+ # Allows to apply lookahead for fields
+ # selected from WidgetInterface
+ override :node_selection
+ def node_selection
+ selected_fields = super
+
+ return unless selected_fields
+
+ selected_fields.selection(:widgets)
+ end
def unconditional_includes
[
@@ -56,6 +60,22 @@ module Resolvers
:author
]
end
+
+ def prepare_finder_params(args)
+ params = super(args)
+ params[:iids] ||= [params.delete(:iid)].compact if params[:iid]
+
+ params
+ end
+
+ def resource_parent
+ # The project could have been loaded in batch by `BatchLoader`.
+ # At this point we need the `id` of the project to query for work items, so
+ # make sure it's loaded and not `nil` before continuing.
+ strong_memoize(:resource_parent) do
+ object.respond_to?(:sync) ? object.sync : object
+ end
+ end
end
end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 1c43432594a..6f64e5b5053 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -17,8 +17,6 @@ module Types
@requires_argument = !!kwargs.delete(:requires_argument)
@authorize = Array.wrap(kwargs.delete(:authorize))
kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity])
- @feature_flag = kwargs[:_deprecated_feature_flag]
- kwargs = check_feature_flag(kwargs)
@deprecation = gitlab_deprecation(kwargs)
after_connection_extensions = kwargs.delete(:late_extensions) || []
@@ -91,16 +89,8 @@ module Types
@constant_complexity
end
- def visible?(context)
- return false if feature_flag.present? && !Feature.enabled?(feature_flag)
-
- super
- end
-
private
- attr_reader :feature_flag
-
def field_authorized?(object, ctx)
object = object.node if object.is_a?(GraphQL::Pagination::Connection::Edge)
@@ -123,27 +113,6 @@ module Types
@authorization ||= ::Gitlab::Graphql::Authorize::ObjectAuthorization.new(@authorize)
end
- def feature_documentation_message(key, description)
- message_parts = ["#{description} Available only when feature flag `#{key}` is enabled."]
-
- message_parts << if Feature::Definition.has_definition?(key) && Feature::Definition.default_enabled?(key)
- "This flag is enabled by default."
- else
- "This flag is disabled by default, because the feature is experimental and is subject to change without notice."
- end
-
- message_parts.join(' ')
- end
-
- def check_feature_flag(args)
- ff = args.delete(:_deprecated_feature_flag)
- return args unless ff.present?
-
- args[:description] = feature_documentation_message(ff, args[:description])
-
- args
- end
-
def field_complexity(resolver_class, current)
return current if current.present? && current > 0
diff --git a/app/graphql/types/branch_protections/base_access_level_type.rb b/app/graphql/types/branch_protections/base_access_level_type.rb
new file mode 100644
index 00000000000..472733a6bc5
--- /dev/null
+++ b/app/graphql/types/branch_protections/base_access_level_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module BranchProtections
+ class BaseAccessLevelType < Types::BaseObject
+ authorize :read_protected_branch
+
+ field :access_level,
+ type: GraphQL::Types::Int,
+ null: false,
+ description: 'GitLab::Access level.'
+
+ field :access_level_description,
+ type: GraphQL::Types::String,
+ null: false,
+ description: 'Human readable representation for this access level.',
+ hash_key: 'humanize'
+ end
+ end
+end
+
+Types::BranchProtections::BaseAccessLevelType.prepend_mod
diff --git a/app/graphql/types/branch_protections/merge_access_level_type.rb b/app/graphql/types/branch_protections/merge_access_level_type.rb
new file mode 100644
index 00000000000..85295e1ba25
--- /dev/null
+++ b/app/graphql/types/branch_protections/merge_access_level_type.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ module BranchProtections
+ class MergeAccessLevelType < BaseAccessLevelType # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'MergeAccessLevel'
+ description 'Represents the merge access level of a branch protection.'
+ accepts ::ProtectedBranch::MergeAccessLevel
+ end
+ end
+end
diff --git a/app/graphql/types/branch_protections/push_access_level_type.rb b/app/graphql/types/branch_protections/push_access_level_type.rb
new file mode 100644
index 00000000000..bfbdc4edbea
--- /dev/null
+++ b/app/graphql/types/branch_protections/push_access_level_type.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ module BranchProtections
+ class PushAccessLevelType < BaseAccessLevelType # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'PushAccessLevel'
+ description 'Represents the push access level of a branch protection.'
+ accepts ::ProtectedBranch::PushAccessLevel
+ end
+ end
+end
diff --git a/app/graphql/types/branch_rules/branch_protection_type.rb b/app/graphql/types/branch_rules/branch_protection_type.rb
new file mode 100644
index 00000000000..4177a6f92a1
--- /dev/null
+++ b/app/graphql/types/branch_rules/branch_protection_type.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Types
+ module BranchRules
+ class BranchProtectionType < BaseObject
+ graphql_name 'BranchProtection'
+ description 'Branch protection details for a branch rule.'
+ accepts ::ProtectedBranch
+ authorize :read_protected_branch
+
+ field :merge_access_levels,
+ type: Types::BranchProtections::MergeAccessLevelType.connection_type,
+ null: true,
+ description: 'Details about who can merge when this branch is the source branch.'
+
+ field :push_access_levels,
+ type: Types::BranchProtections::PushAccessLevelType.connection_type,
+ null: true,
+ description: 'Details about who can push when this branch is the source branch.'
+
+ field :allow_force_push,
+ type: GraphQL::Types::Boolean,
+ null: false,
+ description: 'Toggle force push to the branch for users with write access.'
+ end
+ end
+end
+
+Types::BranchRules::BranchProtectionType.prepend_mod_with('Types::BranchRules::BranchProtectionType')
diff --git a/app/graphql/types/ci/config_variable_type.rb b/app/graphql/types/ci/config_variable_type.rb
new file mode 100644
index 00000000000..87ae026c2c1
--- /dev/null
+++ b/app/graphql/types/ci/config_variable_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class ConfigVariableType < BaseObject # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'CiConfigVariable'
+ description 'CI/CD config variables.'
+
+ field :key, GraphQL::Types::String,
+ null: true,
+ description: 'Name of the variable.'
+
+ field :description, GraphQL::Types::String,
+ null: true,
+ description: 'Description for the CI/CD config variable.'
+
+ field :value, GraphQL::Types::String,
+ null: true,
+ description: 'Value of the variable.'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/group_variable_connection_type.rb b/app/graphql/types/ci/group_variable_connection_type.rb
new file mode 100644
index 00000000000..1f55dde6697
--- /dev/null
+++ b/app/graphql/types/ci/group_variable_connection_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class GroupVariableConnectionType < GraphQL::Types::Relay::BaseConnection
+ field :limit, GraphQL::Types::Int,
+ null: false,
+ description: 'Maximum amount of group CI/CD variables.'
+
+ def limit
+ ::Plan.default.actual_limits.group_ci_variables
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/ci/group_variable_type.rb b/app/graphql/types/ci/group_variable_type.rb
index 3322f741342..f9ed54f0d10 100644
--- a/app/graphql/types/ci/group_variable_type.rb
+++ b/app/graphql/types/ci/group_variable_type.rb
@@ -7,19 +7,20 @@ module Types
graphql_name 'CiGroupVariable'
description 'CI/CD variables for a group.'
+ connection_type_class(Types::Ci::GroupVariableConnectionType)
implements(VariableInterface)
field :environment_scope, GraphQL::Types::String,
- null: true,
- description: 'Scope defining the environments that can use the variable.'
-
- field :protected, GraphQL::Types::Boolean,
- null: true,
- description: 'Indicates whether the variable is protected.'
+ null: true,
+ description: 'Scope defining the environments that can use the variable.'
field :masked, GraphQL::Types::Boolean,
- null: true,
- description: 'Indicates whether the variable is masked.'
+ null: true,
+ description: 'Indicates whether the variable is masked.'
+
+ field :protected, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is protected.'
end
end
end
diff --git a/app/graphql/types/ci/instance_variable_type.rb b/app/graphql/types/ci/instance_variable_type.rb
index f564a2f59a0..7ffc52deb73 100644
--- a/app/graphql/types/ci/instance_variable_type.rb
+++ b/app/graphql/types/ci/instance_variable_type.rb
@@ -9,21 +9,29 @@ module Types
implements(VariableInterface)
+ field :id, GraphQL::Types::ID,
+ null: false,
+ description: 'ID of the variable.'
+
field :environment_scope, GraphQL::Types::String,
- null: true,
- deprecated: {
- reason: 'No longer used, only available for GroupVariableType and ProjectVariableType',
- milestone: '15.3'
- },
- description: 'Scope defining the environments that can use the variable.'
+ null: true,
+ deprecated: {
+ reason: 'No longer used, only available for GroupVariableType and ProjectVariableType',
+ milestone: '15.3'
+ },
+ description: 'Scope defining the environments that can use the variable.'
field :protected, GraphQL::Types::Boolean,
- null: true,
- description: 'Indicates whether the variable is protected.'
+ null: true,
+ description: 'Indicates whether the variable is protected.'
field :masked, GraphQL::Types::Boolean,
- null: true,
- description: 'Indicates whether the variable is masked.'
+ null: true,
+ description: 'Indicates whether the variable is masked.'
+
+ field :raw, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is raw.'
def environment_scope
nil
diff --git a/app/graphql/types/ci/job_artifact_type.rb b/app/graphql/types/ci/job_artifact_type.rb
index a6ab445702c..6346d50de3a 100644
--- a/app/graphql/types/ci/job_artifact_type.rb
+++ b/app/graphql/types/ci/job_artifact_type.rb
@@ -6,6 +6,9 @@ module Types
class JobArtifactType < BaseObject
graphql_name 'CiJobArtifact'
+ field :id, Types::GlobalIDType[::Ci::JobArtifact], null: false,
+ description: 'ID of the artifact.'
+
field :download_path, GraphQL::Types::String, null: true,
description: "URL for downloading the artifact's file."
@@ -16,6 +19,12 @@ module Types
description: 'File name of the artifact.',
method: :filename
+ field :size, GraphQL::Types::Int, null: false,
+ description: 'Size of the artifact in bytes.'
+
+ field :expire_at, Types::TimeType, null: true,
+ description: 'Expiry date of the artifact.'
+
def download_path
::Gitlab::Routing.url_helpers.download_project_job_artifacts_path(
object.project,
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 4ea9a016e74..ab6103d9469 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -92,6 +92,8 @@ module Types
description: 'Indicates the job is stuck.'
field :triggered, GraphQL::Types::Boolean, null: true,
description: 'Whether the job was triggered.'
+ field :web_path, GraphQL::Types::String, null: true,
+ description: 'Web path of the job.'
def kind
return ::Ci::Build unless [::Ci::Build, ::Ci::Bridge].include?(object.class)
@@ -181,6 +183,10 @@ module Types
::Gitlab::Routing.url_helpers.project_commits_path(object.project, ref_name)
end
+ def web_path
+ ::Gitlab::Routing.url_helpers.project_job_path(object.project, object)
+ end
+
def coverage
object&.coverage
end
diff --git a/app/graphql/types/ci/manual_variable_type.rb b/app/graphql/types/ci/manual_variable_type.rb
index d6f59c1d249..ed92a6645b4 100644
--- a/app/graphql/types/ci/manual_variable_type.rb
+++ b/app/graphql/types/ci/manual_variable_type.rb
@@ -10,12 +10,12 @@ module Types
implements(VariableInterface)
field :environment_scope, GraphQL::Types::String,
- null: true,
- deprecated: {
- reason: 'No longer used, only available for GroupVariableType and ProjectVariableType',
- milestone: '15.3'
- },
- description: 'Scope defining the environments that can use the variable.'
+ null: true,
+ deprecated: {
+ reason: 'No longer used, only available for GroupVariableType and ProjectVariableType',
+ milestone: '15.3'
+ },
+ description: 'Scope defining the environments that can use the variable.'
def environment_scope
nil
diff --git a/app/graphql/types/ci/project_variable_connection_type.rb b/app/graphql/types/ci/project_variable_connection_type.rb
new file mode 100644
index 00000000000..c3cdc425f10
--- /dev/null
+++ b/app/graphql/types/ci/project_variable_connection_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class ProjectVariableConnectionType < GraphQL::Types::Relay::BaseConnection
+ field :limit, GraphQL::Types::Int,
+ null: false,
+ description: 'Maximum amount of project CI/CD variables.'
+
+ def limit
+ ::Plan.default.actual_limits.project_ci_variables
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/ci/project_variable_type.rb b/app/graphql/types/ci/project_variable_type.rb
index 625bb7fd4b1..2a5375045e5 100644
--- a/app/graphql/types/ci/project_variable_type.rb
+++ b/app/graphql/types/ci/project_variable_type.rb
@@ -7,19 +7,20 @@ module Types
graphql_name 'CiProjectVariable'
description 'CI/CD variables for a project.'
+ connection_type_class(Types::Ci::ProjectVariableConnectionType)
implements(VariableInterface)
field :environment_scope, GraphQL::Types::String,
- null: true,
- description: 'Scope defining the environments that can use the variable.'
+ null: true,
+ description: 'Scope defining the environments that can use the variable.'
field :protected, GraphQL::Types::Boolean,
- null: true,
- description: 'Indicates whether the variable is protected.'
+ null: true,
+ description: 'Indicates whether the variable is protected.'
field :masked, GraphQL::Types::Boolean,
- null: true,
- description: 'Indicates whether the variable is masked.'
+ null: true,
+ description: 'Indicates whether the variable is masked.'
end
end
end
diff --git a/app/graphql/types/ci/runner_membership_filter_enum.rb b/app/graphql/types/ci/runner_membership_filter_enum.rb
index 2e1051b2151..4fd7e0749b0 100644
--- a/app/graphql/types/ci/runner_membership_filter_enum.rb
+++ b/app/graphql/types/ci/runner_membership_filter_enum.rb
@@ -3,15 +3,17 @@
module Types
module Ci
class RunnerMembershipFilterEnum < BaseEnum
- graphql_name 'RunnerMembershipFilter'
- description 'Values for filtering runners in namespaces.'
+ graphql_name 'CiRunnerMembershipFilter'
+ description 'Values for filtering runners in namespaces. ' \
+ 'The previous type name `RunnerMembershipFilter` was deprecated in 15.4.'
value 'DIRECT',
description: "Include runners that have a direct relationship.",
value: :direct
value 'DESCENDANTS',
- description: "Include runners that have either a direct relationship or a relationship with descendants. These can be project runners or group runners (in the case where group is queried).",
+ description: "Include runners that have either a direct or inherited relationship. " \
+ "These runners can be specific to a project or a group.",
value: :descendants
end
end
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 0afb61d2b64..a9c76974850 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -52,7 +52,7 @@ module Types
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.',
+ description: 'Jobs assigned to the runner. This field can only be resolved for one runner in any single request.',
authorize: :read_builds,
resolver: ::Resolvers::Ci::RunnerJobsResolver
field :locked, GraphQL::Types::Boolean, null: true,
@@ -63,8 +63,11 @@ module Types
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 :projects,
+ ::Types::ProjectType.connection_type,
+ null: true,
+ resolver: ::Resolvers::Ci::RunnerProjectsResolver,
+ description: 'Find 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,
@@ -131,12 +134,6 @@ module Types
batched_owners(::Ci::RunnerNamespace, Group, :runner_groups, :namespace_id)
end
- def projects
- return unless runner.project_type?
-
- batched_owners(::Ci::RunnerProject, Project, :runner_projects, :project_id)
- end
-
private
def can_admin_runners?
@@ -159,19 +156,12 @@ module Types
owner_ids = runner_owner_ids_by_runner_id.values.flatten.uniq
owners = assoc_type.where(id: owner_ids).index_by(&:id)
- # Preload projects namespaces to avoid N+1 queries when checking the `read_project` policy for each
- preload_projects_namespaces(owners.values) if assoc_type == Project
-
runner_ids.each do |runner_id|
loader.call(runner_id, runner_owner_ids_by_runner_id[runner_id]&.map { |owner_id| owners[owner_id] } || [])
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
-
- def preload_projects_namespaces(_projects)
- # overridden in EE
- end
end
end
end
diff --git a/app/graphql/types/ci/variable_interface.rb b/app/graphql/types/ci/variable_interface.rb
index 82c9ba7121c..ec68d3c987c 100644
--- a/app/graphql/types/ci/variable_interface.rb
+++ b/app/graphql/types/ci/variable_interface.rb
@@ -8,24 +8,24 @@ module Types
graphql_name 'CiVariable'
field :id, GraphQL::Types::ID,
- null: false,
- description: 'ID of the variable.'
+ null: false,
+ description: 'ID of the variable.'
field :key, GraphQL::Types::String,
- null: true,
- description: 'Name of the variable.'
+ null: true,
+ description: 'Name of the variable.'
+
+ field :raw, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is raw.'
field :value, GraphQL::Types::String,
- null: true,
- description: 'Value of the variable.'
+ null: true,
+ description: 'Value of the variable.'
field :variable_type, ::Types::Ci::VariableTypeEnum,
- null: true,
- description: 'Type of the variable.'
-
- field :raw, GraphQL::Types::Boolean,
- null: true,
- description: 'Indicates whether the variable is raw.'
+ null: true,
+ description: 'Type of the variable.'
end
end
end
diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb
index 546252b2285..5d7b8815cde 100644
--- a/app/graphql/types/clusters/agent_type.rb
+++ b/app/graphql/types/clusters/agent_type.rb
@@ -71,3 +71,5 @@ module Types
end
end
end
+
+Types::Clusters::AgentType.prepend_mod
diff --git a/app/graphql/types/customer_relations/contact_sort_enum.rb b/app/graphql/types/customer_relations/contact_sort_enum.rb
index 221dedacb6a..bb11d741368 100644
--- a/app/graphql/types/customer_relations/contact_sort_enum.rb
+++ b/app/graphql/types/customer_relations/contact_sort_enum.rb
@@ -11,10 +11,10 @@ module Types
sortable_fields.each do |field|
value "#{field.upcase.tr(' ', '_')}_ASC",
value: { field: field.downcase.tr(' ', '_'), direction: :asc },
- description: "#{field} by ascending order."
+ description: "#{field} in ascending order."
value "#{field.upcase.tr(' ', '_')}_DESC",
value: { field: field.downcase.tr(' ', '_'), direction: :desc },
- description: "#{field} by descending order."
+ description: "#{field} in descending order."
end
end
end
diff --git a/app/graphql/types/customer_relations/organization_sort_enum.rb b/app/graphql/types/customer_relations/organization_sort_enum.rb
new file mode 100644
index 00000000000..742a5a4fa99
--- /dev/null
+++ b/app/graphql/types/customer_relations/organization_sort_enum.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module CustomerRelations
+ class OrganizationSortEnum < SortEnum
+ graphql_name 'OrganizationSort'
+ description 'Values for sorting organizations'
+
+ sortable_fields = ['Name', 'Description', 'Default Rate']
+
+ sortable_fields.each do |field|
+ value "#{field.upcase.tr(' ', '_')}_ASC",
+ value: { field: field.downcase.tr(' ', '_'), direction: :asc },
+ description: "#{field} in ascending order."
+ value "#{field.upcase.tr(' ', '_')}_DESC",
+ value: { field: field.downcase.tr(' ', '_'), direction: :desc },
+ description: "#{field} in descending order."
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/customer_relations/organization_state_counts_type.rb b/app/graphql/types/customer_relations/organization_state_counts_type.rb
new file mode 100644
index 00000000000..7d813209a8e
--- /dev/null
+++ b/app/graphql/types/customer_relations/organization_state_counts_type.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Types
+ module CustomerRelations
+ # `object` is a hash. Authorization is performed by OrganizationStateCountsResolver
+ class OrganizationStateCountsType < Types::BaseObject # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'OrganizationStateCounts'
+ description 'Represents the total number of organizations for the represented states.'
+
+ AVAILABLE_STATES = ::CustomerRelations::Organization.states.keys.push('all').freeze
+
+ AVAILABLE_STATES.each do |state|
+ field state,
+ GraphQL::Types::Int,
+ null: true,
+ description: "Number of organizations with state `#{state.upcase}`"
+ end
+
+ def all
+ object.values.sum
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/customer_relations/organization_state_enum.rb b/app/graphql/types/customer_relations/organization_state_enum.rb
index ecdd7d092ad..84bbbbc90fc 100644
--- a/app/graphql/types/customer_relations/organization_state_enum.rb
+++ b/app/graphql/types/customer_relations/organization_state_enum.rb
@@ -5,12 +5,16 @@ module Types
class OrganizationStateEnum < BaseEnum
graphql_name 'CustomerRelationsOrganizationState'
+ value 'all',
+ description: "All available organizations.",
+ value: :all
+
value 'active',
- description: "Active organization.",
+ description: "Active organizations.",
value: :active
value 'inactive',
- description: "Inactive organization.",
+ description: "Inactive organizations.",
value: :inactive
end
end
diff --git a/app/graphql/types/deployment_details_type.rb b/app/graphql/types/deployment_details_type.rb
new file mode 100644
index 00000000000..f8ba0cb1b24
--- /dev/null
+++ b/app/graphql/types/deployment_details_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ class DeploymentDetailsType < DeploymentType
+ graphql_name 'DeploymentDetails'
+ description 'The details of the deployment'
+ authorize :read_deployment
+ present_using Deployments::DeploymentPresenter
+
+ field :tags,
+ [Types::DeploymentTagType],
+ description: 'Git tags that contain this deployment.',
+ calls_gitaly: true
+ end
+end
diff --git a/app/graphql/types/deployment_status_enum.rb b/app/graphql/types/deployment_status_enum.rb
new file mode 100644
index 00000000000..7ef69d3f1c1
--- /dev/null
+++ b/app/graphql/types/deployment_status_enum.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ class DeploymentStatusEnum < BaseEnum
+ graphql_name 'DeploymentStatus'
+ description 'All deployment statuses.'
+
+ ::Deployment.statuses.each_key do |status|
+ value status.upcase,
+ description: "A deployment that is #{status.tr('_', ' ')}.",
+ value: status
+ end
+ end
+end
diff --git a/app/graphql/types/deployment_tag_type.rb b/app/graphql/types/deployment_tag_type.rb
new file mode 100644
index 00000000000..bc3597404a2
--- /dev/null
+++ b/app/graphql/types/deployment_tag_type.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Types
+ # DeploymentTagType is a hash, authorized by the deployment
+ # rubocop:disable Graphql/AuthorizeTypes
+ class DeploymentTagType < BaseObject
+ graphql_name 'DeploymentTag'
+ description 'Tags for a given deployment'
+
+ field :name,
+ GraphQL::Types::String,
+ description: 'Name of this git tag.'
+
+ field :path,
+ GraphQL::Types::String,
+ description: 'Path for this tag.',
+ hash_key: :path
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/deployment_type.rb b/app/graphql/types/deployment_type.rb
new file mode 100644
index 00000000000..70a3a4cb574
--- /dev/null
+++ b/app/graphql/types/deployment_type.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Types
+ # If you're considering to add a new field in DeploymentType, please follow this guideline:
+ # - If the field is preloadable in batch, define it in DeploymentType.
+ # In this case, you should extend DeploymentsResolver logic to preload the field. Also, add a new test that
+ # fetching the specific field for multiple deployments doesn't cause N+1 query problem.
+ # - If the field is NOT preloadable in batch, define it in DeploymentDetailsType.
+ # This type can be only fetched for a single deployment, so you don't need to take care of the preloading.
+ class DeploymentType < BaseObject
+ graphql_name 'Deployment'
+ description 'The deployment of an environment'
+
+ present_using Deployments::DeploymentPresenter
+
+ authorize :read_deployment
+
+ field :id,
+ GraphQL::Types::ID,
+ description: 'Global ID of the deployment.'
+
+ field :iid,
+ GraphQL::Types::ID,
+ description: 'Project-level internal ID of the deployment.'
+
+ field :ref,
+ GraphQL::Types::String,
+ description: 'Git-Ref that the deployment ran on.'
+
+ field :tag,
+ GraphQL::Types::Boolean,
+ description: 'True or false if the deployment ran on a Git-tag.'
+
+ field :sha,
+ GraphQL::Types::String,
+ description: 'Git-SHA that the deployment ran on.'
+
+ field :created_at,
+ Types::TimeType,
+ description: 'When the deployment record was created.'
+
+ field :updated_at,
+ Types::TimeType,
+ description: 'When the deployment record was updated.'
+
+ field :finished_at,
+ Types::TimeType,
+ description: 'When the deployment finished.'
+
+ field :status,
+ Types::DeploymentStatusEnum,
+ description: 'Status of the deployment.'
+
+ field :commit,
+ Types::CommitType,
+ description: 'Commit details of the deployment.',
+ calls_gitaly: true
+
+ field :job,
+ Types::Ci::JobType,
+ description: 'Pipeline job of the deployment.',
+ method: :build
+
+ field :triggerer,
+ Types::UserType,
+ description: 'User who executed the deployment.',
+ method: :deployed_by
+ end
+end
diff --git a/app/graphql/types/deployments_order_by_input_type.rb b/app/graphql/types/deployments_order_by_input_type.rb
new file mode 100644
index 00000000000..a87fef9fe8a
--- /dev/null
+++ b/app/graphql/types/deployments_order_by_input_type.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Types
+ class DeploymentsOrderByInputType < BaseInputObject
+ graphql_name 'DeploymentsOrderByInput'
+ description 'Values for ordering deployments by a specific field'
+
+ argument :created_at,
+ Types::SortDirectionEnum,
+ required: false,
+ description: 'Order by Created time.'
+
+ argument :finished_at,
+ Types::SortDirectionEnum,
+ required: false,
+ description: 'Order by Finished time.'
+
+ def prepare
+ raise GraphQL::ExecutionError, 'orderBy parameter must contain one key-value pair.' unless to_h.size == 1
+
+ super
+ end
+ end
+end
diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb
index 2a7076cc3c9..eb4e7b1dabf 100644
--- a/app/graphql/types/environment_type.rb
+++ b/app/graphql/types/environment_type.rb
@@ -21,6 +21,30 @@ module Types
field :path, GraphQL::Types::String, null: false,
description: 'Path to the environment.'
+ field :slug, GraphQL::Types::String,
+ description: 'Slug of the environment.'
+
+ field :external_url, GraphQL::Types::String, null: true,
+ description: 'External URL of the environment.'
+
+ field :created_at, Types::TimeType,
+ description: 'When the environment was created.'
+
+ field :updated_at, Types::TimeType,
+ description: 'When the environment was updated.'
+
+ field :auto_stop_at, Types::TimeType,
+ description: 'When the environment is going to be stopped automatically.'
+
+ field :auto_delete_at, Types::TimeType,
+ description: 'When the environment is going to be deleted automatically.'
+
+ field :tier, Types::DeploymentTierEnum,
+ description: 'Deployment tier of the environment.'
+
+ field :environment_type, GraphQL::Types::String,
+ description: 'Folder name of the environment.'
+
field :metrics_dashboard, Types::Metrics::DashboardType, null: true,
description: 'Metrics dashboard schema for the environment.',
resolver: Resolvers::Metrics::DashboardResolver
@@ -29,5 +53,22 @@ module Types
Types::AlertManagement::AlertType,
null: true,
description: 'Most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.'
+
+ field :deployments,
+ Types::DeploymentType.connection_type,
+ null: true,
+ description: 'Deployments of the environment. This field can only be resolved for one project in any single request.',
+ resolver: Resolvers::DeploymentsResolver do
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
+ end
+
+ field :last_deployment,
+ Types::DeploymentType,
+ description: 'Last deployment of the environment.',
+ resolver: Resolvers::Environments::LastDeploymentResolver
+
+ def tier
+ object.tier.to_sym
+ end
end
end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 235a2bc2a34..45357de5502 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -22,7 +22,7 @@ module Types
type: Types::CustomEmojiType.connection_type,
null: true,
description: 'Custom emoji within this namespace.',
- _deprecated_feature_flag: :custom_emoji
+ alpha: { milestone: '13.6' }
field :share_with_group_lock,
type: GraphQL::Types::Boolean,
@@ -134,7 +134,7 @@ module Types
description: 'Number of container repositories in the group.'
field :packages,
- description: 'Packages of the group.',
+ description: 'Packages of the group. This field can only be resolved for one group in any single request.',
resolver: Resolvers::GroupPackagesResolver
field :dependency_proxy_setting,
@@ -212,6 +212,12 @@ module Types
description: "Find organizations of this group.",
resolver: Resolvers::Crm::OrganizationsResolver
+ field :organization_state_counts,
+ Types::CustomerRelations::OrganizationStateCountsType,
+ null: true,
+ description: 'Counts of organizations by status for the group.',
+ resolver: Resolvers::Crm::OrganizationStateCountsResolver
+
field :contacts, Types::CustomerRelations::ContactType.connection_type,
null: true,
description: "Find contacts of this group.",
@@ -272,6 +278,10 @@ module Types
group.dependency_proxy_setting || group.create_dependency_proxy_setting
end
+ def custom_emoji
+ object.custom_emoji if Feature.enabled?(:custom_emoji)
+ end
+
private
def group
diff --git a/app/graphql/types/member_sort_enum.rb b/app/graphql/types/member_sort_enum.rb
new file mode 100644
index 00000000000..f3291dda13b
--- /dev/null
+++ b/app/graphql/types/member_sort_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ class MemberSortEnum < SortEnum
+ graphql_name 'MemberSort'
+ description 'Values for sorting members'
+
+ value 'ACCESS_LEVEL_ASC', 'Access level ascending order.', value: :access_level_asc
+ value 'ACCESS_LEVEL_DESC', 'Access level descending order.', value: :access_level_desc
+ value 'USER_FULL_NAME_ASC', "User's full name ascending order.", value: :name_asc
+ value 'USER_FULL_NAME_DESC', "User's full name descending order.", value: :name_desc
+ end
+end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index d88653f2f8c..399dcc8e03d 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -94,9 +94,10 @@ module Types
method: :public_merge_status, null: true,
description: 'Merge status of the merge request.'
- field :detailed_merge_status, ::Types::MergeRequests::DetailedMergeStatusEnum, method: :detailed_merge_status, null: true,
+ field :detailed_merge_status, ::Types::MergeRequests::DetailedMergeStatusEnum, null: true,
calls_gitaly: true,
- description: 'Detailed merge status of the merge request.', alpha: { milestone: '15.3' }
+ description: 'Detailed merge status of the merge request.',
+ alpha: { milestone: '15.3' }
field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true,
calls_gitaly: true,
@@ -280,6 +281,10 @@ module Types
def merge_user
object.metrics&.merged_by || object.merge_user
end
+
+ def detailed_merge_status
+ ::MergeRequests::Mergeability::DetailedMergeStatusService.new(merge_request: object).execute
+ end
end
end
diff --git a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb
index 58104159303..3de6296154d 100644
--- a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb
+++ b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb
@@ -21,6 +21,9 @@ module Types
value 'CI_MUST_PASS',
value: :ci_must_pass,
description: 'Pipeline must succeed before merging.'
+ value 'CI_STILL_RUNNING',
+ value: :ci_still_running,
+ description: 'Pipeline is still running.'
value 'DISCUSSIONS_NOT_RESOLVED',
value: :discussions_not_resolved,
description: 'Discussions must be resolved before merging.'
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 499c2e786bf..ea833b35085 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -37,8 +37,8 @@ module Types
mount_mutation Mutations::Clusters::AgentTokens::Create
mount_mutation Mutations::Clusters::AgentTokens::Revoke
mount_mutation Mutations::Commits::Create, calls_gitaly: true
- mount_mutation Mutations::CustomEmoji::Create, _deprecated_feature_flag: :custom_emoji
- mount_mutation Mutations::CustomEmoji::Destroy, _deprecated_feature_flag: :custom_emoji
+ mount_mutation Mutations::CustomEmoji::Create, alpha: { milestone: '13.6' }
+ mount_mutation Mutations::CustomEmoji::Destroy, alpha: { milestone: '13.6' }
mount_mutation Mutations::CustomerRelations::Contacts::Create
mount_mutation Mutations::CustomerRelations::Contacts::Update
mount_mutation Mutations::CustomerRelations::Organizations::Create
@@ -120,10 +120,12 @@ module Types
milestone: '15.0'
}
mount_mutation Mutations::Ci::ProjectCiCdSettingsUpdate
+ mount_mutation Mutations::Ci::Job::ArtifactsDestroy
mount_mutation Mutations::Ci::Job::Play
mount_mutation Mutations::Ci::Job::Retry
mount_mutation Mutations::Ci::Job::Cancel
mount_mutation Mutations::Ci::Job::Unschedule
+ mount_mutation Mutations::Ci::JobArtifact::Destroy
mount_mutation Mutations::Ci::JobTokenScope::AddProject
mount_mutation Mutations::Ci::JobTokenScope::RemoveProject
mount_mutation Mutations::Ci::Runner::Update
diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb
index 0413177ef14..6c0d955ed77 100644
--- a/app/graphql/types/packages/package_details_type.rb
+++ b/app/graphql/types/packages/package_details_type.rb
@@ -26,6 +26,8 @@ module Types
field :pypi_setup_url, GraphQL::Types::String, null: true, description: 'Url of the PyPi project setup endpoint.'
field :pypi_url, GraphQL::Types::String, null: true, description: 'Url of the PyPi project endpoint.'
+ field :last_downloaded_at, Types::TimeType, null: true, description: 'Last time that a file of this package was downloaded.'
+
def versions
object.versions
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index ecc6c9d7811..f43f5c27dac 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -10,119 +10,204 @@ module Types
expose_permissions Types::PermissionTypes::Project
- 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 :sast_ci_configuration, Types::CiConfiguration::Sast::Type, null: true,
- calls_gitaly: true,
- description: 'SAST CI configuration for the project.'
-
- 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).', method: :topic_list
-
- field :topics, [GraphQL::Types::String], null: true,
- description: 'List of project topics.', method: :topic_list
-
- 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 :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.'
- field :last_activity_at, Types::TimeType, null: true,
- description: 'Timestamp of the project last activity.'
-
- field :archived, GraphQL::Types::Boolean, null: true,
- description: 'Indicates the archived status of the project.'
-
- field :visibility, GraphQL::Types::String, null: true,
- description: 'Visibility of 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.'
-
- field :service_desk_address, GraphQL::Types::String, null: true,
- description: 'E-mail address of the Service Desk.'
-
- field :avatar_url, GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'URL to avatar image file of the project.'
-
- field :jobs_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.'
-
- field :public_jobs, GraphQL::Types::Boolean, method: :public_builds, null: true,
- description: 'Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts.'
-
- field :open_issues_count, GraphQL::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_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 :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.'
+ 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 :ci_config_variables, [Types::Ci::ConfigVariableType],
+ null: true,
+ calls_gitaly: true,
+ authorize: :create_pipeline,
+ alpha: { milestone: '15.3' },
+ description: 'CI/CD config variable.' do
+ argument :sha, GraphQL::Types::String,
+ required: true,
+ description: 'Sha.'
+ end
+
+ 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 :sast_ci_configuration, Types::CiConfiguration::Sast::Type,
+ null: true,
+ calls_gitaly: true,
+ description: 'SAST CI configuration for the project.'
+
+ 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).',
+ method: :topic_list
+
+ field :topics, [GraphQL::Types::String],
+ null: true,
+ description: 'List of project topics.',
+ method: :topic_list
+
+ 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 :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.'
+
+ field :last_activity_at, Types::TimeType,
+ null: true,
+ description: 'Timestamp of the project last activity.'
+
+ field :archived, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates the archived status of the project.'
+
+ field :visibility, GraphQL::Types::String,
+ null: true,
+ description: 'Visibility of 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.'
+
+ field :service_desk_address, GraphQL::Types::String,
+ null: true,
+ description: 'E-mail address of the Service Desk.'
+
+ field :avatar_url, GraphQL::Types::String,
+ null: true,
+ calls_gitaly: true,
+ description: 'URL to avatar image file of the project.'
+
+ field :jobs_enabled, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.'
+
+ field :public_jobs, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates if there is public access to pipelines and job details of the project, ' \
+ 'including output logs and artifacts.',
+ method: :public_builds
+
+ 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_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 :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,
+ description: 'Indicates if `squashReadOnly` is enabled.',
+ method: :squash_readonly?
+
+ 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 :namespace, Types::NamespaceType,
+ null: true,
+ description: 'Namespace of the project.'
field :statistics, Types::ProjectStatisticsType,
null: true,
description: 'Statistics of the project.'
- field :repository, Types::RepositoryType, null: true,
- description: 'Git repository of the project.'
+ field :repository, Types::RepositoryType,
+ null: true,
+ description: 'Git repository of the project.'
field :merge_requests,
Types::MergeRequestType.connection_type,
@@ -159,9 +244,10 @@ module Types
extras: [:lookahead],
resolver: Resolvers::IssueStatusCountsResolver
- field :milestones, Types::MilestoneType.connection_type, null: true,
- description: 'Milestones of the project.',
- resolver: Resolvers::ProjectMilestonesResolver
+ field :milestones, Types::MilestoneType.connection_type,
+ null: true,
+ description: 'Milestones of the project.',
+ resolver: Resolvers::ProjectMilestonesResolver
field :project_members,
description: 'Members of the project.',
@@ -179,6 +265,12 @@ module Types
description: 'A single environment of the project.',
resolver: Resolvers::EnvironmentsResolver.single
+ field :deployment,
+ Types::DeploymentDetailsType,
+ null: true,
+ description: 'Details of the deployment of the project.',
+ resolver: Resolvers::DeploymentResolver.single
+
field :issue,
Types::IssueType,
null: true,
@@ -201,164 +293,150 @@ module Types
description: 'Jobs of a project. This field can only be resolved for one project in any single request.',
resolver: Resolvers::ProjectJobsResolver
+ field :job,
+ type: Types::Ci::JobType,
+ null: true,
+ authorize: :read_build,
+ description: 'One job belonging to the project, selected by ID.' do
+ argument :id, Types::GlobalIDType[::CommitStatus],
+ required: true,
+ description: 'ID of the job.'
+ end
+
field :pipelines,
null: true,
description: 'Build pipelines of the project.',
extras: [:lookahead],
resolver: Resolvers::ProjectPipelinesResolver
- field :pipeline,
- Types::Ci::PipelineType,
+ field :pipeline, Types::Ci::PipelineType,
null: true,
description: 'Build pipeline of the project.',
extras: [:lookahead],
resolver: Resolvers::ProjectPipelineResolver
- field :pipeline_counts,
- Types::Ci::PipelineCountsType,
+ field :pipeline_counts, Types::Ci::PipelineCountsType,
null: true,
description: 'Build pipeline counts of the project.',
resolver: Resolvers::Ci::ProjectPipelineCountsResolver
- field :ci_variables,
- Types::Ci::ProjectVariableType.connection_type,
+ field :ci_variables, Types::Ci::ProjectVariableType.connection_type,
null: true,
description: "List of the project's CI/CD variables.",
authorize: :admin_build,
method: :variables
- field :ci_cd_settings,
- Types::Ci::CiCdSettingType,
+ field :ci_cd_settings, Types::Ci::CiCdSettingType,
null: true,
description: 'CI/CD settings for the project.'
- field :sentry_detailed_error,
- Types::ErrorTracking::SentryDetailedErrorType,
+ field :sentry_detailed_error, Types::ErrorTracking::SentryDetailedErrorType,
null: true,
description: 'Detailed version of a Sentry error on the project.',
resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver
- field :grafana_integration,
- Types::GrafanaIntegrationType,
+ field :grafana_integration, Types::GrafanaIntegrationType,
null: true,
description: 'Grafana integration details for the project.',
resolver: Resolvers::Projects::GrafanaIntegrationResolver
- field :snippets,
- Types::SnippetType.connection_type,
+ field :snippets, Types::SnippetType.connection_type,
null: true,
description: 'Snippets of the project.',
resolver: Resolvers::Projects::SnippetsResolver
- field :sentry_errors,
- Types::ErrorTracking::SentryErrorCollectionType,
+ field :sentry_errors, Types::ErrorTracking::SentryErrorCollectionType,
null: true,
description: 'Paginated collection of Sentry errors on the project.',
resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver
- field :boards,
- Types::BoardType.connection_type,
+ field :boards, Types::BoardType.connection_type,
null: true,
description: 'Boards of the project.',
max_page_size: 2000,
resolver: Resolvers::BoardsResolver
- field :recent_issue_boards,
- Types::BoardType.connection_type,
+ field :recent_issue_boards, Types::BoardType.connection_type,
null: true,
description: 'List of recently visited boards of the project. Maximum size is 4.',
resolver: Resolvers::RecentBoardsResolver
- field :board,
- Types::BoardType,
+ field :board, Types::BoardType,
null: true,
description: 'A single board of the project.',
resolver: Resolvers::BoardResolver
- field :jira_imports,
- Types::JiraImportType.connection_type,
+ field :jira_imports, Types::JiraImportType.connection_type,
null: true,
description: 'Jira imports into the project.'
- field :services,
- Types::Projects::ServiceType.connection_type,
+ field :services, Types::Projects::ServiceType.connection_type,
null: true,
description: 'Project services.',
resolver: Resolvers::Projects::ServicesResolver
- field :alert_management_alerts,
- Types::AlertManagement::AlertType.connection_type,
+ field :alert_management_alerts, Types::AlertManagement::AlertType.connection_type,
null: true,
description: 'Alert Management alerts of the project.',
extras: [:lookahead],
resolver: Resolvers::AlertManagement::AlertResolver
- field :alert_management_alert,
- Types::AlertManagement::AlertType,
+ field :alert_management_alert, Types::AlertManagement::AlertType,
null: true,
description: 'A single Alert Management alert of the project.',
resolver: Resolvers::AlertManagement::AlertResolver.single
- field :alert_management_alert_status_counts,
- Types::AlertManagement::AlertStatusCountsType,
+ field :alert_management_alert_status_counts, Types::AlertManagement::AlertStatusCountsType,
null: true,
description: 'Counts of alerts by status for the project.',
resolver: Resolvers::AlertManagement::AlertStatusCountsResolver
- field :alert_management_integrations,
- Types::AlertManagement::IntegrationType.connection_type,
+ field :alert_management_integrations, Types::AlertManagement::IntegrationType.connection_type,
null: true,
description: 'Integrations which can receive alerts for the project.',
resolver: Resolvers::AlertManagement::IntegrationsResolver
- field :alert_management_http_integrations,
- Types::AlertManagement::HttpIntegrationType.connection_type,
+ field :alert_management_http_integrations, Types::AlertManagement::HttpIntegrationType.connection_type,
null: true,
description: 'HTTP Integrations which can receive alerts for the project.',
resolver: Resolvers::AlertManagement::HttpIntegrationsResolver
- field :incident_management_timeline_events,
- Types::IncidentManagement::TimelineEventType.connection_type,
+ field :incident_management_timeline_events, Types::IncidentManagement::TimelineEventType.connection_type,
null: true,
description: 'Incident Management Timeline events associated with the incident.',
extras: [:lookahead],
resolver: Resolvers::IncidentManagement::TimelineEventsResolver
- field :incident_management_timeline_event,
- Types::IncidentManagement::TimelineEventType,
+ field :incident_management_timeline_event, Types::IncidentManagement::TimelineEventType,
null: true,
description: 'Incident Management Timeline event associated with the incident.',
resolver: Resolvers::IncidentManagement::TimelineEventsResolver.single
- field :releases,
- Types::ReleaseType.connection_type,
+ field :releases, Types::ReleaseType.connection_type,
null: true,
description: 'Releases of the project.',
resolver: Resolvers::ReleasesResolver
- field :release,
- Types::ReleaseType,
+ field :release, Types::ReleaseType,
null: true,
description: 'A single release of the project.',
resolver: Resolvers::ReleasesResolver.single,
authorize: :read_release
- field :container_expiration_policy,
- Types::ContainerExpirationPolicyType,
+ field :container_expiration_policy, Types::ContainerExpirationPolicyType,
null: true,
description: 'Container expiration policy of the project.'
- field :container_repositories,
- Types::ContainerRepositoryType.connection_type,
+ field :container_repositories, Types::ContainerRepositoryType.connection_type,
null: true,
description: 'Container repositories of the project.',
resolver: Resolvers::ContainerRepositoriesResolver
- field :container_repositories_count, GraphQL::Types::Int, null: false,
- description: 'Number of container repositories in the project.'
+ field :container_repositories_count, GraphQL::Types::Int,
+ null: false,
+ description: 'Number of container repositories in the project.'
- field :label,
- Types::LabelType,
+ field :label, Types::LabelType,
null: true,
description: 'Label available on this project.' do
argument :title, GraphQL::Types::String,
@@ -366,68 +444,63 @@ module Types
description: 'Title of the label.'
end
- field :terraform_state,
- Types::Terraform::StateType,
+ field :terraform_state, Types::Terraform::StateType,
null: true,
description: 'Find a single Terraform state by name.',
resolver: Resolvers::Terraform::StatesResolver.single
- field :terraform_states,
- Types::Terraform::StateType.connection_type,
+ field :terraform_states, Types::Terraform::StateType.connection_type,
null: true,
description: 'Terraform states associated with the project.',
resolver: Resolvers::Terraform::StatesResolver
- field :pipeline_analytics, Types::Ci::AnalyticsType, null: true,
- description: 'Pipeline analytics.',
- resolver: Resolvers::ProjectPipelineStatisticsResolver
+ field :pipeline_analytics, Types::Ci::AnalyticsType,
+ null: true,
+ description: 'Pipeline analytics.',
+ resolver: Resolvers::ProjectPipelineStatisticsResolver
- field :ci_template, Types::Ci::TemplateType, null: true,
- description: 'Find a single CI/CD template by name.',
- resolver: Resolvers::Ci::TemplateResolver
+ field :ci_template, Types::Ci::TemplateType,
+ null: true,
+ description: 'Find a single CI/CD template by name.',
+ resolver: Resolvers::Ci::TemplateResolver
- field :ci_job_token_scope, Types::Ci::JobTokenScopeType, null: true,
- description: 'The CI Job Tokens scope of access.',
- resolver: Resolvers::Ci::JobTokenScopeResolver
+ field :ci_job_token_scope, Types::Ci::JobTokenScopeType,
+ null: true,
+ description: 'The CI Job Tokens scope of access.',
+ resolver: Resolvers::Ci::JobTokenScopeResolver
- field :timelogs,
- Types::TimelogType.connection_type, null: true,
- description: 'Time logged on issues and merge requests in the project.',
- extras: [:lookahead],
- complexity: 5,
- resolver: ::Resolvers::TimelogResolver
+ field :timelogs, Types::TimelogType.connection_type,
+ null: true,
+ description: 'Time logged on issues and merge requests in the project.',
+ extras: [:lookahead],
+ complexity: 5,
+ resolver: ::Resolvers::TimelogResolver
- field :agent_configurations,
- ::Types::Kas::AgentConfigurationType.connection_type,
+ field :agent_configurations, ::Types::Kas::AgentConfigurationType.connection_type,
null: true,
description: 'Agent configurations defined by the project',
resolver: ::Resolvers::Kas::AgentConfigurationsResolver
- field :cluster_agent,
- ::Types::Clusters::AgentType,
+ field :cluster_agent, ::Types::Clusters::AgentType,
null: true,
description: 'Find a single cluster agent by name.',
resolver: ::Resolvers::Clusters::AgentsResolver.single
- field :cluster_agents,
- ::Types::Clusters::AgentType.connection_type,
+ field :cluster_agents, ::Types::Clusters::AgentType.connection_type,
extras: [:lookahead],
null: true,
description: 'Cluster agents associated with the project.',
resolver: ::Resolvers::Clusters::AgentsResolver
- field :merge_commit_template,
- GraphQL::Types::String,
+ field :merge_commit_template, GraphQL::Types::String,
null: true,
description: 'Template used to create merge commit message in merge requests.'
- field :squash_commit_template,
- GraphQL::Types::String,
+ field :squash_commit_template, GraphQL::Types::String,
null: true,
description: 'Template used to create squash commit message in merge requests.'
- field :labels,
- Types::LabelType.connection_type,
+ field :labels, Types::LabelType.connection_type,
null: true,
description: 'Labels available on this project.',
resolver: Resolvers::LabelsResolver
@@ -438,8 +511,7 @@ module Types
' 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.'
- field :timelog_categories,
- Types::TimeTracking::TimelogCategoryType.connection_type,
+ field :timelog_categories, Types::TimeTracking::TimelogCategoryType.connection_type,
null: true,
description: "Timelog categories for the project.",
alpha: { milestone: '15.3' }
@@ -448,6 +520,12 @@ module Types
resolver: Resolvers::Projects::ForkTargetsResolver,
description: 'Namespaces in which the current user can fork the project into.'
+ field :branch_rules,
+ Types::Projects::BranchRuleType.connection_type,
+ null: true,
+ description: "Branch rules configured for the project.",
+ resolver: Resolvers::Projects::BranchRulesResolver
+
def timelog_categories
object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories)
end
@@ -498,6 +576,21 @@ module Types
project.container_repositories.size
end
+ def ci_config_variables(sha:)
+ result = ::Ci::ListConfigVariablesService.new(object, context[:current_user]).execute(sha)
+
+ return if result.nil?
+
+ result.map do |var_key, var_config|
+ { key: var_key, **var_config }
+ end
+ end
+
+ def job(id:)
+ object.commit_statuses.find(id.model_id)
+ rescue ActiveRecord::RecordNotFound
+ end
+
def sast_ci_configuration
return unless Ability.allowed?(current_user, :download_code, object)
diff --git a/app/graphql/types/projects/branch_rule_type.rb b/app/graphql/types/projects/branch_rule_type.rb
new file mode 100644
index 00000000000..866cff0f439
--- /dev/null
+++ b/app/graphql/types/projects/branch_rule_type.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Types
+ module Projects
+ class BranchRuleType < BaseObject
+ graphql_name 'BranchRule'
+ description 'List of branch rules for a project, grouped by branch name.'
+ accepts ::ProtectedBranch
+ authorize :read_protected_branch
+
+ field :name,
+ type: GraphQL::Types::String,
+ null: false,
+ description: 'Branch name, with wildcards, for the branch rules.'
+
+ field :branch_protection,
+ type: Types::BranchRules::BranchProtectionType,
+ null: false,
+ description: 'Branch protections configured for this branch rule.',
+ method: :itself
+
+ field :created_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp of when the branch rule was created.'
+
+ field :updated_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp of when the branch rule was last updated.'
+ end
+ end
+end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 84355390ea0..78463a1804a 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -67,7 +67,7 @@ module Types
end
field :package,
- description: 'Find a package.',
+ description: 'Find a package. This field can only be resolved for one query in any single request.',
resolver: Resolvers::PackageDetailsResolver
field :user, Types::UserType,
diff --git a/app/graphql/types/sort_direction_enum.rb b/app/graphql/types/sort_direction_enum.rb
new file mode 100644
index 00000000000..28dba1abfb6
--- /dev/null
+++ b/app/graphql/types/sort_direction_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class SortDirectionEnum < BaseEnum
+ graphql_name 'SortDirectionEnum'
+ description 'Values for sort direction'
+
+ value 'ASC', 'Ascending order.', value: 'asc'
+ value 'DESC', 'Descending order.', value: 'desc'
+ end
+end
diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb
index 9b5f028a857..ef701bbfc10 100644
--- a/app/graphql/types/subscription_type.rb
+++ b/app/graphql/types/subscription_type.rb
@@ -18,5 +18,12 @@ module Types
field :issuable_dates_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the due date or start date of an issuable is updated.'
+
+ field :merge_request_reviewers_updated,
+ subscription: Subscriptions::IssuableUpdated,
+ null: true,
+ description: 'Triggered when the reviewers of a merge request are updated.'
end
end
+
+Types::SubscriptionType.prepend_mod
diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb
index c3fb9b77927..3856e1aa3b3 100644
--- a/app/graphql/types/timelog_type.rb
+++ b/app/graphql/types/timelog_type.rb
@@ -4,7 +4,7 @@ module Types
class TimelogType < BaseObject
graphql_name 'Timelog'
- authorize :read_issue
+ authorize :read_issuable
expose_permissions Types::PermissionTypes::Timelog
diff --git a/app/graphql/types/work_items/widgets/description_type.rb b/app/graphql/types/work_items/widgets/description_type.rb
index 4c365a67bfd..4861f7f46d8 100644
--- a/app/graphql/types/work_items/widgets/description_type.rb
+++ b/app/graphql/types/work_items/widgets/description_type.rb
@@ -13,8 +13,18 @@ module Types
implements Types::WorkItems::WidgetInterface
field :description, GraphQL::Types::String,
- null: true,
- description: 'Description of the work item.'
+ null: true,
+ description: 'Description of the work item.'
+ field :edited, GraphQL::Types::Boolean,
+ null: false,
+ description: 'Whether the description has been edited since the work item was created.',
+ method: :edited?
+ field :last_edited_at, Types::TimeType,
+ null: true,
+ description: 'Timestamp of when the work item\'s description was last edited.'
+ field :last_edited_by, Types::UserType,
+ null: true,
+ description: 'User that made the last edit to the work item\'s description.'
markdown_field :description_html, null: true do |resolved_object|
resolved_object.work_item
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 321a6e9395e..ddc682bc08a 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -7,6 +7,7 @@ module ApplicationSettingsHelper
:gravatar_enabled?,
:password_authentication_enabled_for_web?,
:akismet_enabled?,
+ :spam_check_endpoint_enabled?,
to: :'Gitlab::CurrentSettings.current_application_settings'
def user_oauth_applications?
@@ -60,6 +61,10 @@ module ApplicationSettingsHelper
all_protocols_enabled? || Gitlab::CurrentSettings.enabled_git_access_protocol == 'http'
end
+ def anti_spam_service_enabled?
+ akismet_enabled? || spam_check_endpoint_enabled?
+ end
+
def enabled_protocol_button(container, protocol)
case protocol
when 'ssh'
@@ -278,6 +283,7 @@ module ApplicationSettingsHelper
:max_export_size,
:max_import_size,
:max_pages_size,
+ :max_pages_custom_domains_per_project,
:max_yaml_size_bytes,
:max_yaml_depth,
:metrics_method_call_threshold,
@@ -434,12 +440,24 @@ module ApplicationSettingsHelper
:runner_token_expiration_interval,
:group_runner_token_expiration_interval,
:project_runner_token_expiration_interval,
- :pipeline_limit_per_project_user_sha
+ :pipeline_limit_per_project_user_sha,
+ :invitation_flow_enforcement
].tap do |settings|
- settings << :deactivate_dormant_users unless Gitlab.com?
+ next if Gitlab.com?
+
+ settings << :deactivate_dormant_users
+ settings << :deactivate_dormant_users_period
end
end
+ def runner_token_expiration_interval_attributes
+ {
+ instance_runner_token_expiration_interval: @application_setting.runner_token_expiration_interval,
+ group_runner_token_expiration_interval: @application_setting.group_runner_token_expiration_interval,
+ project_runner_token_expiration_interval: @application_setting.project_runner_token_expiration_interval
+ }
+ end
+
def external_authorization_service_attributes
[
:external_auth_client_cert,
diff --git a/app/helpers/badges_helper.rb b/app/helpers/badges_helper.rb
index d48eae26a90..069c15433a5 100644
--- a/app/helpers/badges_helper.rb
+++ b/app/helpers/badges_helper.rb
@@ -1,25 +1,6 @@
# frozen_string_literal: true
module BadgesHelper
- VARIANT_CLASSES = {
- muted: "badge-muted",
- neutral: "badge-neutral",
- info: "badge-info",
- success: "badge-success",
- warning: "badge-warning",
- danger: "badge-danger"
- }.tap { |hash| hash.default = hash.fetch(:muted) }.freeze
-
- SIZE_CLASSES = {
- sm: "sm",
- md: "md",
- lg: "lg"
- }.tap { |hash| hash.default = hash.fetch(:md) }.freeze
-
- GL_BADGE_CLASSES = %w[gl-badge badge badge-pill].freeze
-
- GL_ICON_CLASSES = %w[gl-icon gl-badge-icon].freeze
-
# Creates a GitLab UI badge.
#
# Examples:
@@ -53,47 +34,16 @@ module BadgesHelper
#
# See also https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-badge--default.
def gl_badge_tag(*args, &block)
+ # Merge the options and html_options hashes if both are present,
+ # because the badge component wants a flat list of keyword args.
+ args.compact!
+ hashes, params = args.partition { |a| a.is_a? Hash }
+ options_hash = hashes.reduce({}, :merge)
+
if block
- build_gl_badge_tag(capture(&block), *args)
+ render Pajamas::BadgeComponent.new(**options_hash), &block
else
- build_gl_badge_tag(*args)
+ render Pajamas::BadgeComponent.new(*params, **options_hash)
end
end
-
- private
-
- def build_gl_badge_tag(content, options = nil, html_options = nil)
- options ||= {}
- html_options ||= {}
-
- icon_only = options[:icon_only]
- variant_class = VARIANT_CLASSES[options.fetch(:variant, :muted)]
- size_class = SIZE_CLASSES[options.fetch(:size, :md)]
- icon_classes = GL_ICON_CLASSES.dup << options.fetch(:icon_classes, nil)
-
- html_options = html_options.merge(
- class: [
- *GL_BADGE_CLASSES,
- variant_class,
- size_class,
- *html_options[:class]
- ]
- )
-
- if icon_only
- html_options['aria-label'] = content
- html_options['role'] = 'img'
- end
-
- if options[:icon]
- icon_classes << "gl-mr-2" unless icon_only
- icon = sprite_icon(options[:icon], css_class: icon_classes.join(' '))
-
- content = icon_only ? icon : icon + content
- end
-
- tag = html_options[:href].nil? ? :span : :a
-
- content_tag(tag, content, html_options)
- end
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 2c84da4862a..6c09e15f56f 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -92,32 +92,6 @@ module BlobHelper
end
end
- def replace_blob_link(project = @project, ref = @ref, path = @path, blob:)
- modify_file_button(
- project,
- ref,
- path,
- blob: blob,
- label: _("Replace"),
- action: "replace",
- btn_class: "default",
- modal_type: "upload"
- )
- end
-
- def delete_blob_link(project = @project, ref = @ref, path = @path, blob:)
- modify_file_button(
- project,
- ref,
- path,
- blob: blob,
- label: _("Delete"),
- action: "delete",
- btn_class: "default",
- modal_type: "remove"
- )
- end
-
def can_modify_blob?(blob, project = @project, ref = @ref)
!blob.stored_externally? && can_edit_tree?(project, ref)
end
diff --git a/app/helpers/ci/builds_helper.rb b/app/helpers/ci/builds_helper.rb
index b4a2cf7bb1e..afd0af18ba7 100644
--- a/app/helpers/ci/builds_helper.rb
+++ b/app/helpers/ci/builds_helper.rb
@@ -25,7 +25,7 @@ module Ci
{
page_path: project_job_path(@project, @build),
build_status: @build.status,
- build_stage: @build.stage,
+ build_stage: @build.stage_name,
log_state: ''
}
end
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index 6d63151769f..7b8290ac9ef 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -11,7 +11,7 @@ module Ci
"runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'),
"page_path" => project_job_path(@project, @build),
"build_status" => @build.status,
- "build_stage" => @build.stage,
+ "build_stage" => @build.stage_name,
"log_state" => '',
"build_options" => javascript_build_options,
"retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs')
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index 852eaeca5e3..0de84c0d61f 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -84,7 +84,6 @@ module Ci
def group_runners_data_attributes(group)
{
- registration_token: group.runners_token,
group_id: group.id,
group_full_path: group.full_path,
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
diff --git a/app/helpers/deploy_tokens_helper.rb b/app/helpers/deploy_tokens_helper.rb
index 560d2fcd29f..597823cdac7 100644
--- a/app/helpers/deploy_tokens_helper.rb
+++ b/app/helpers/deploy_tokens_helper.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
module DeployTokensHelper
- def expand_deploy_tokens_section?(deploy_token)
- deploy_token.persisted? ||
- deploy_token.errors.present? ||
+ def expand_deploy_tokens_section?(new_deploy_token, created_deploy_token)
+ created_deploy_token ||
+ new_deploy_token.errors.present? ||
Rails.env.test?
end
@@ -14,7 +14,7 @@ module DeployTokensHelper
def packages_registry_enabled?(group_or_project)
Gitlab.config.packages.enabled &&
- can?(current_user, :read_package, group_or_project)
+ can?(current_user, :read_package, group_or_project&.packages_policy_subject)
end
def deploy_token_revoke_button_data(token:, group_or_project:)
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 457502347ee..5c3b9d4b5ab 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -140,12 +140,12 @@ module DiffHelper
if compare_url
link_text = [
- _('Compare'),
- ' ',
- content_tag(:span, Commit.truncate_sha(diff_file.old_blob.id), class: 'commit-sha'),
- '...',
- content_tag(:span, Commit.truncate_sha(diff_file.blob.id), class: 'commit-sha')
- ].join('').html_safe
+ _('Compare'),
+ ' ',
+ content_tag(:span, Commit.truncate_sha(diff_file.old_blob.id), class: 'commit-sha'),
+ '...',
+ content_tag(:span, Commit.truncate_sha(diff_file.blob.id), class: 'commit-sha')
+ ].join('').html_safe
tooltip = _('Compare submodule commit revisions')
link = content_tag(:span, link_to(link_text, compare_url, class: 'btn gl-button has-tooltip', title: tooltip), class: 'submodule-compare')
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index a910d3d7c9d..62e66b9a3ea 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
module DropdownsHelper
+ # rubocop:disable Metrics/CyclomaticComplexity
def dropdown_tag(toggle_text, options: {}, &block)
content_tag :div, class: "dropdown #{options[:wrapper_class] if options.key?(:wrapper_class)}" do
data_attr = { toggle: "dropdown" }
@@ -16,7 +17,8 @@ module DropdownsHelper
end
content_tag_options = { class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}" }
- content_tag_options[:data] = { qa_selector: "#{options[:dropdown_qa_selector]}" } if options[:dropdown_qa_selector]
+ content_tag_options[:data] = options[:dropdown_qa_selector] ? { qa_selector: "#{options[:dropdown_qa_selector]}" } : {}
+ content_tag_options[:data][:testid] = "#{options[:dropdown_testid]}" if options[:dropdown_testid]
dropdown_output << content_tag(:div, content_tag_options) do
output = []
@@ -46,6 +48,7 @@ module DropdownsHelper
dropdown_output.html_safe
end
end
+ # rubocop:enable Metrics/CyclomaticComplexity
def dropdown_toggle(toggle_text, data_attr, options = {})
default_label = data_attr[:default_label]
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index f74eeeb8c6a..f2e24f54391 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module FormHelper
- def form_errors(model, type: 'form', truncate: [], pajamas_alert: true)
+ def form_errors(model, type: 'form', truncate: [])
errors = model.errors
return unless errors.any?
@@ -64,7 +64,7 @@ module FormHelper
field_name: "#{issuable_type}[assignee_ids][]",
default_label: _('Unassigned'),
'max-select': 1,
- 'dropdown-header': _('Assignee'),
+ 'dropdown-header': s_('SearchToken|Assignee'),
multi_select: true,
'input-meta': 'name',
'always-show-selectbox': true,
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index bb92792de2d..f77bd6621f9 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -172,6 +172,15 @@ module GroupsHelper
}
end
+ def group_overview_tabs_app_data(group)
+ {
+ subgroups_and_projects_endpoint: group_children_path(group, format: :json),
+ shared_projects_endpoint: group_shared_projects_path(group, format: :json),
+ archived_projects_endpoint: group_children_path(group, format: :json, archived: 'only'),
+ current_group_visibility: group.visibility
+ }.merge(subgroups_and_projects_list_app_data(group))
+ end
+
def enabled_git_access_protocol_options_for_group
case ::Gitlab::CurrentSettings.enabled_git_access_protocol
when nil, ""
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 4b463b9971d..ec1327cf7ae 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -24,7 +24,8 @@ module IdeHelper
'web-terminal-svg-path' => image_path('illustrations/web-ide_promotion.svg'),
'web-terminal-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'interactive-web-terminals-for-the-web-ide'),
'web-terminal-config-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'web-ide-configuration-file'),
- 'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration')
+ 'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration'),
+ 'csp-nonce' => content_security_policy_nonce
}
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 8fd004233e2..96daf398243 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -156,7 +156,7 @@ module IssuablesHelper
output = []
if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.work_item_type.base_type)
- output << content_tag(:span, sprite_icon("#{issuable.work_item_type.icon_name}", css_class: 'gl-icon gl-vertical-align-middle'), class: 'gl-mr-2', aria: { hidden: 'true' })
+ output << content_tag(:span, sprite_icon("#{issuable.work_item_type.icon_name}", css_class: 'gl-icon gl-vertical-align-middle gl-text-gray-500'), class: 'gl-mr-2', aria: { hidden: 'true' })
output << s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) }
else
output << s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) }
@@ -240,6 +240,7 @@ module IssuablesHelper
updateEndpoint: "#{issuable_path(issuable)}.json",
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
+ canUpdateTimelineEvent: can?(current_user, :admin_incident_management_timeline_event, issuable),
issuableRef: issuable.to_reference,
markdownPreviewPath: preview_markdown_path(parent, target_type: issuable.model_name, target_id: issuable.iid),
markdownDocsPath: help_page_path('user/markdown'),
diff --git a/app/helpers/javascript_helper.rb b/app/helpers/javascript_helper.rb
deleted file mode 100644
index 7cb6da26236..00000000000
--- a/app/helpers/javascript_helper.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-module JavascriptHelper
- def page_specific_javascript_tag(js)
- javascript_include_tag asset_path(js)
- end
-end
diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb
index 4ddfb0224d1..0971fdae8dd 100644
--- a/app/helpers/jira_connect_helper.rb
+++ b/app/helpers/jira_connect_helper.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module JiraConnectHelper
- def jira_connect_app_data(subscriptions)
+ def jira_connect_app_data(subscriptions, installation)
skip_groups = subscriptions.map(&:namespace_id)
{
@@ -11,14 +11,16 @@ module JiraConnectHelper
subscriptions_path: jira_connect_subscriptions_path(format: :json),
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,
- oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data.to_json : nil
+ oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data(installation).to_json : nil
}
end
private
- def jira_connect_oauth_data
- oauth_authorize_url = oauth_authorization_url(
+ def jira_connect_oauth_data(installation)
+ oauth_instance_url = installation.oauth_authorization_url
+
+ oauth_authorize_path = oauth_authorization_path(
client_id: Gitlab::CurrentSettings.jira_connect_application_key,
response_type: 'code',
scope: 'api',
@@ -27,8 +29,8 @@ module JiraConnectHelper
)
{
- oauth_authorize_url: oauth_authorize_url,
- oauth_token_url: oauth_token_url,
+ oauth_authorize_url: Gitlab::Utils.append_path(oauth_instance_url, oauth_authorize_path),
+ oauth_token_path: oauth_token_path,
state: oauth_state,
oauth_token_payload: {
grant_type: :authorization_code,
diff --git a/app/helpers/kerberos_spnego_helper.rb b/app/helpers/kerberos_helper.rb
index 0f6812bc31b..31166772367 100644
--- a/app/helpers/kerberos_spnego_helper.rb
+++ b/app/helpers/kerberos_helper.rb
@@ -1,13 +1,13 @@
# frozen_string_literal: true
-module KerberosSpnegoHelper
+module KerberosHelper
def allow_basic_auth?
true # different behavior in GitLab Enterprise Edition
end
- def allow_kerberos_spnego_auth?
+ def allow_kerberos_auth?
false # different behavior in GitLab Enterprise Edition
end
end
-KerberosSpnegoHelper.prepend_mod_with('KerberosSpnegoHelper')
+KerberosHelper.prepend_mod_with('KerberosHelper')
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index e865db128c1..0123eb68c9a 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -87,7 +87,7 @@ module LabelsHelper
'#013220' => s_('SuggestedColors|Dark green'),
'#6699cc' => s_('SuggestedColors|Blue-gray'),
'#0000ff' => s_('SuggestedColors|Blue'),
- '#e6e6fa' => s_('SuggestedColors|Lavendar'),
+ '#e6e6fa' => s_('SuggestedColors|Lavender'),
'#9400d3' => s_('SuggestedColors|Dark violet'),
'#330066' => s_('SuggestedColors|Deep violet'),
'#808080' => s_('SuggestedColors|Gray'),
diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb
index 421cf84f98c..a07922e451a 100644
--- a/app/helpers/learn_gitlab_helper.rb
+++ b/app/helpers/learn_gitlab_helper.rb
@@ -21,8 +21,8 @@ module LearnGitlabHelper
end
def learn_gitlab_onboarding_available?(project)
- OnboardingProgress.onboarding?(project.namespace) &&
- LearnGitlab::Project.new(current_user).available?
+ Onboarding::Progress.onboarding?(project.namespace) &&
+ Onboarding::LearnGitlab.new(current_user).available?
end
private
@@ -33,10 +33,12 @@ module LearnGitlabHelper
action_urls(project).to_h do |action, url|
[
action,
- url: url,
- completed: attributes[OnboardingProgress.column_name(action)].present?,
- svg: image_path("learn_gitlab/#{action}.svg"),
- enabled: true
+ {
+ url: url,
+ completed: attributes[Onboarding::Progress.column_name(action)].present?,
+ svg: image_path("learn_gitlab/#{action}.svg"),
+ enabled: true
+ }
]
end
end
@@ -70,11 +72,14 @@ module LearnGitlabHelper
end
def action_issue_urls
- LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) }
+ Onboarding::Completion::ACTION_ISSUE_IDS.transform_values do |id|
+ project_issue_url(learn_gitlab_project, id)
+ end
end
def deploy_section_action_urls(project)
- experiment(:security_actions_continuous_onboarding,
+ experiment(
+ :security_actions_continuous_onboarding,
namespace: project.namespace,
user: current_user,
sticky_to: current_user
@@ -91,11 +96,11 @@ module LearnGitlabHelper
end
def learn_gitlab_project
- @learn_gitlab_project ||= LearnGitlab::Project.new(current_user).project
+ @learn_gitlab_project ||= Onboarding::LearnGitlab.new(current_user).project
end
def onboarding_progress(project)
- OnboardingProgress.find_by(namespace: project.namespace) # rubocop: disable CodeReuse/ActiveRecord
+ Onboarding::Progress.find_by(namespace: project.namespace) # rubocop: disable CodeReuse/ActiveRecord
end
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 4581da4a063..45ded6e35d8 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -256,6 +256,26 @@ module MergeRequestsHelper
def moved_mr_sidebar_enabled?
Feature.enabled?(:moved_mr_sidebar, @project) && defined?(@merge_request)
end
+
+ def sticky_header_data
+ data = {
+ iid: @merge_request.iid,
+ projectPath: @project.full_path,
+ title: markdown_field(@merge_request, :title),
+ isFluidLayout: fluid_layout.to_s,
+ tabs: [
+ ['show', _('Overview'), project_merge_request_path(@project, @merge_request), @merge_request.related_notes.user.count],
+ ['commits', _('Commits'), commits_project_merge_request_path(@project, @merge_request), @commits_count],
+ ['diffs', _('Changes'), diffs_project_merge_request_path(@project, @merge_request), @diffs_count]
+ ]
+ }
+
+ if @project.builds_enabled?
+ data[:tabs].insert(2, ['pipelines', _('Pipelines'), pipelines_project_merge_request_path(@project, @merge_request), @number_of_pipelines])
+ end
+
+ data
+ end
end
MergeRequestsHelper.prepend_mod_with('MergeRequestsHelper')
diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb
index dc7d8049556..b017c9a81d1 100644
--- a/app/helpers/nav/new_dropdown_helper.rb
+++ b/app/helpers/nav/new_dropdown_helper.rb
@@ -135,7 +135,7 @@ module Nav
id: 'general_new_group',
title: _('New group'),
href: new_group_path,
- data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_group_link' }
)
)
end
diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb
index efec6f2d0d8..32d3f4aebb4 100644
--- a/app/helpers/nav/top_nav_helper.rb
+++ b/app/helpers/nav/top_nav_helper.rb
@@ -48,6 +48,13 @@ module Nav
private
+ def top_nav_localized_headers
+ {
+ explore: s_('TopNav|Explore'),
+ switch_to: s_('TopNav|Switch to')
+ }.freeze
+ end
+
def build_base_view_model(builder:, project:, group:)
if current_user
build_view_model(builder: builder, project: project, group: group)
@@ -60,6 +67,7 @@ module Nav
# These come from `app/views/layouts/nav/_explore.html.ham`
if explore_nav_link?(:projects)
builder.add_primary_menu_item_with_shortcut(
+ header: top_nav_localized_headers[:explore],
href: explore_root_path,
active: nav == 'project' || active_nav_link?(path: %w[dashboard#show root#show projects#trending projects#starred projects#index]),
**projects_menu_item_attrs
@@ -68,6 +76,7 @@ module Nav
if explore_nav_link?(:groups)
builder.add_primary_menu_item_with_shortcut(
+ header: top_nav_localized_headers[:explore],
href: explore_groups_path,
active: nav == 'group' || active_nav_link?(controller: [:groups, 'groups/milestones', 'groups/group_members']),
**groups_menu_item_attrs
@@ -76,6 +85,7 @@ module Nav
if explore_nav_link?(:snippets)
builder.add_primary_menu_item_with_shortcut(
+ header: top_nav_localized_headers[:explore],
active: active_nav_link?(controller: :snippets),
href: explore_snippets_path,
**snippets_menu_item_attrs
@@ -89,6 +99,7 @@ module Nav
current_item = project ? current_project(project: project) : {}
builder.add_primary_menu_item_with_shortcut(
+ header: top_nav_localized_headers[:switch_to],
active: nav == 'project' || active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]),
css_class: 'qa-projects-dropdown',
data: { track_label: "projects_dropdown", track_action: "click_dropdown" },
@@ -103,6 +114,7 @@ module Nav
current_item = group ? current_group(group: group) : {}
builder.add_primary_menu_item_with_shortcut(
+ header: top_nav_localized_headers[:switch_to],
active: nav == 'group' || active_nav_link?(path: %w[dashboard/groups explore/groups]),
css_class: 'qa-groups-dropdown',
data: { track_label: "groups_dropdown", track_action: "click_dropdown" },
@@ -116,6 +128,7 @@ module Nav
if dashboard_nav_link?(:milestones)
builder.add_primary_menu_item_with_shortcut(
id: 'milestones',
+ header: top_nav_localized_headers[:explore],
title: _('Milestones'),
href: dashboard_milestones_path,
active: active_nav_link?(controller: 'dashboard/milestones'),
@@ -127,6 +140,7 @@ module Nav
if dashboard_nav_link?(:snippets)
builder.add_primary_menu_item_with_shortcut(
+ header: top_nav_localized_headers[:explore],
active: active_nav_link?(controller: 'dashboard/snippets'),
data: { qa_selector: 'snippets_link', **menu_data_tracking_attrs('snippets') },
href: dashboard_snippets_path,
@@ -137,6 +151,7 @@ module Nav
if dashboard_nav_link?(:activity)
builder.add_primary_menu_item_with_shortcut(
id: 'activity',
+ header: top_nav_localized_headers[:explore],
title: _('Activity'),
href: activity_dashboard_path,
active: active_nav_link?(path: 'dashboard#activity'),
@@ -266,52 +281,74 @@ module Nav
end
def projects_submenu_items(builder:)
- # These project links come from `app/views/layouts/nav/projects_dropdown/_show.html.haml`
- [
- { id: 'your', title: _('Your projects'), href: dashboard_projects_path },
- { id: 'starred', title: _('Starred projects'), href: starred_dashboard_projects_path },
- { id: 'explore', title: _('Explore projects'), href: explore_root_path },
- { id: 'topics', title: _('Explore topics'), href: topics_explore_projects_path }
- ].each do |item|
+ if Feature.enabled?(:remove_extra_primary_submenu_options)
+ title = _('View all projects')
+
builder.add_primary_menu_item(
- **item,
- data: { qa_selector: 'menu_item_link', qa_title: item[:title], **menu_data_tracking_attrs(item[:title]) }
+ id: 'your',
+ title: title,
+ href: dashboard_projects_path,
+ data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
)
- end
+ else
+ # These project links come from `app/views/layouts/nav/projects_dropdown/_show.html.haml`
+ [
+ { id: 'your', title: _('Your projects'), href: dashboard_projects_path },
+ { id: 'starred', title: _('Starred projects'), href: starred_dashboard_projects_path },
+ { id: 'explore', title: _('Explore projects'), href: explore_root_path },
+ { id: 'topics', title: _('Explore topics'), href: topics_explore_projects_path }
+ ].each do |item|
+ builder.add_primary_menu_item(
+ **item,
+ data: { qa_selector: 'menu_item_link', qa_title: item[:title], **menu_data_tracking_attrs(item[:title]) }
+ )
+ end
- title = _('Create new project')
+ title = _('Create new project')
- builder.add_secondary_menu_item(
- id: 'create',
- title: title,
- href: new_project_path,
- data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
- )
+ builder.add_secondary_menu_item(
+ id: 'create',
+ title: title,
+ href: new_project_path,
+ data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
+ )
+ end
end
def groups_submenu
# These group links come from `app/views/layouts/nav/groups_dropdown/_show.html.haml`
builder = ::Gitlab::Nav::TopNavMenuBuilder.new
- [
- { id: 'your', title: _('Your groups'), href: dashboard_groups_path },
- { id: 'explore', title: _('Explore groups'), href: explore_groups_path }
- ].each do |item|
- builder.add_primary_menu_item(
- **item,
- data: { qa_selector: 'menu_item_link', qa_title: item[:title], **menu_data_tracking_attrs(item[:title]) }
- )
- end
+ if Feature.enabled?(:remove_extra_primary_submenu_options)
+ title = _('View all groups')
- if current_user.can_create_group?
- title = _('Create group')
-
- builder.add_secondary_menu_item(
- id: 'create',
+ builder.add_primary_menu_item(
+ id: 'your',
title: title,
- href: new_group_path,
+ href: dashboard_groups_path,
data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
)
+ else
+ [
+ { id: 'your', title: _('Your groups'), href: dashboard_groups_path },
+ { id: 'explore', title: _('Explore groups'), href: explore_groups_path }
+ ].each do |item|
+ builder.add_primary_menu_item(
+ **item,
+ data: { qa_selector: 'menu_item_link', qa_title: item[:title], **menu_data_tracking_attrs(item[:title]) }
+ )
+ end
+
+ if current_user.can_create_group?
+ title = _('Create group')
+
+ builder.add_secondary_menu_item(
+ id: 'create',
+ title: title,
+ href: new_group_path,
+ data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
+ )
+ end
end
builder.build
diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb
index c0ba93f4a30..b7ab1c2e2d1 100644
--- a/app/helpers/notify_helper.rb
+++ b/app/helpers/notify_helper.rb
@@ -20,4 +20,15 @@ module NotifyHelper
(source.description || default_description).truncate(200, separator: ' ')
end
+
+ def merge_request_hash_param(merge_request, reviewer)
+ {
+ mr_highlight: '<span style="font-weight: 600;color:#333333;">'.html_safe,
+ highlight_end: '</span>'.html_safe,
+ mr_link: link_to(merge_request.to_reference, merge_request_url(merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none").html_safe,
+ reviewer_highlight: '<span>'.html_safe,
+ reviewer_avatar: content_tag(:img, nil, height: "24", src: avatar_icon_for_user(reviewer, 24, only_path: false), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar", class: "avatar").html_safe,
+ reviewer_link: link_to(reviewer.name, user_url(reviewer), style: "color:#333333;text-decoration:none;", class: "muted").html_safe
+ }
+ end
end
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index b52357bc891..f9ec20bdd01 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -73,6 +73,7 @@ module PackagesHelper
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'),
+ project_settings_path: project_settings_packages_and_registries_path(@project),
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_link: show_cleanup_policy_link(@project).to_s,
@@ -83,7 +84,8 @@ module PackagesHelper
def settings_data
cleanup_settings_data.merge(
show_container_registry_settings: show_container_registry_settings(@project).to_s,
- show_package_registry_settings: show_package_registry_settings(@project).to_s
+ show_package_registry_settings: show_package_registry_settings(@project).to_s,
+ cleanup_settings_path: cleanup_image_tags_project_settings_packages_and_registries_path(@project)
)
end
end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index 0c057a29bec..c0665463706 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -150,6 +150,10 @@ module PageLayoutHelper
css_class.join(' ')
end
+ def full_content_class
+ "#{container_class} #{@content_class}" # rubocop:disable Rails/HelperInstanceVariable
+ end
+
def page_itemtype(itemtype = nil)
if itemtype
@page_itemtype = { itemscope: true, itemtype: itemtype }
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 104026ff21e..bfe39bbc211 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -53,7 +53,7 @@ module ProfilesHelper
# Overridden in EE::ProfilesHelper#ssh_key_expires_field_description
def ssh_key_expires_field_description
- s_('Profiles|Key becomes invalid on this date.')
+ s_('Profiles|Optional but recommended. If set, key becomes invalid on the specified date.')
end
# Overridden in EE::ProfilesHelper#ssh_key_expiration_policy_enabled?
diff --git a/app/helpers/projects/google_cloud/cloudsql_helper.rb b/app/helpers/projects/google_cloud/cloudsql_helper.rb
new file mode 100644
index 00000000000..0c24254d9b4
--- /dev/null
+++ b/app/helpers/projects/google_cloud/cloudsql_helper.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+module Projects
+ module GoogleCloud
+ module CloudsqlHelper
+ # Sources:
+ # - https://cloud.google.com/sql/docs/postgres/instance-settings
+ # - https://cloud.google.com/sql/docs/mysql/instance-settings
+ # - https://cloud.google.com/sql/docs/sqlserver/instance-settings
+
+ TIERS = [
+ { value: 'db-custom-1-3840', label: '1 vCPU, 3840 MB RAM - Standard' },
+ { value: 'db-custom-2-7680', label: '2 vCPU, 7680 MB RAM - Standard' },
+ { value: 'db-custom-2-13312', label: '2 vCPU, 13312 MB RAM - High memory' },
+ { value: 'db-custom-4-15360', label: '4 vCPU, 15360 MB RAM - Standard' },
+ { value: 'db-custom-4-26624', label: '4 vCPU, 26624 MB RAM - High memory' },
+ { value: 'db-custom-8-30720', label: '8 vCPU, 30720 MB RAM - Standard' },
+ { value: 'db-custom-8-53248', label: '8 vCPU, 53248 MB RAM - High memory' },
+ { value: 'db-custom-16-61440', label: '16 vCPU, 61440 MB RAM - Standard' },
+ { value: 'db-custom-16-106496', label: '16 vCPU, 106496 MB RAM - High memory' },
+ { value: 'db-custom-32-122880', label: '32 vCPU, 122880 MB RAM - Standard' },
+ { value: 'db-custom-32-212992', label: '32 vCPU, 212992 MB RAM - High memory' },
+ { value: 'db-custom-64-245760', label: '64 vCPU, 245760 MB RAM - Standard' },
+ { value: 'db-custom-64-425984', label: '64 vCPU, 425984 MB RAM - High memory' },
+ { value: 'db-custom-96-368640', label: '96 vCPU, 368640 MB RAM - Standard' },
+ { value: 'db-custom-96-638976', label: '96 vCPU, 638976 MB RAM - High memory' }
+ ].freeze
+
+ VERSIONS = {
+ postgres: [
+ { value: 'POSTGRES_14', label: 'PostgreSQL 14' },
+ { value: 'POSTGRES_13', label: 'PostgreSQL 13' },
+ { value: 'POSTGRES_12', label: 'PostgreSQL 12' },
+ { value: 'POSTGRES_11', label: 'PostgreSQL 11' },
+ { value: 'POSTGRES_10', label: 'PostgreSQL 10' },
+ { value: 'POSTGRES_9_6', label: 'PostgreSQL 9.6' }
+ ],
+ mysql: [
+ { value: 'MYSQL_8_0', label: 'MySQL 8' },
+ { value: 'MYSQL_5_7', label: 'MySQL 5.7' },
+ { value: 'MYSQL_5_6', label: 'MySQL 5.6' }
+ ],
+ sqlserver: [
+ { value: 'SQLSERVER_2017_STANDARD', label: 'SQL Server 2017 Standard' },
+ { value: 'SQLSERVER_2017_ENTERPRISE', label: 'SQL Server 2017 Enterprise' },
+ { value: 'SQLSERVER_2017_EXPRESS', label: 'SQL Server 2017 Express' },
+ { value: 'SQLSERVER_2017_WEB', label: 'SQL Server 2017 Web' },
+ { value: 'SQLSERVER_2019_STANDARD', label: 'SQL Server 2019 Standard' },
+ { value: 'SQLSERVER_2019_ENTERPRISE', label: 'SQL Server 2019 Enterprise' },
+ { value: 'SQLSERVER_2019_EXPRESS', label: 'SQL Server 2019 Express' },
+ { value: 'SQLSERVER_2019_WEB', label: 'SQL Server 2019 Web' }
+ ]
+ }.freeze
+ end
+ end
+end
diff --git a/app/helpers/projects/pages_helper.rb b/app/helpers/projects/pages_helper.rb
new file mode 100644
index 00000000000..f46c11db1db
--- /dev/null
+++ b/app/helpers/projects/pages_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Projects
+ module PagesHelper
+ def can_create_pages_custom_domains?(current_user, project)
+ current_user.can?(:update_pages, project) &&
+ (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) &&
+ project.can_create_custom_domains?
+ end
+ end
+end
diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb
index 5f2a9f7bf21..c72beb4d722 100644
--- a/app/helpers/projects/pipeline_helper.rb
+++ b/app/helpers/projects/pipeline_helper.rb
@@ -6,7 +6,6 @@ module Projects
def js_pipeline_tabs_data(project, pipeline, _user)
{
- can_generate_codequality_reports: pipeline.can_generate_codequality_reports?.to_json,
failed_jobs_count: pipeline.failed_builds.count,
failed_jobs_summary: prepare_failed_jobs_summary_data(pipeline.failed_builds),
full_path: project.full_path,
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index dfc270adf8b..e760fad7be9 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -172,6 +172,7 @@ module ProjectsHelper
def project_list_cache_key(project, pipeline_status: true)
key = [
+ project.star_count,
project.route.cache_key,
project.cache_key,
project.last_activity_date,
@@ -389,7 +390,10 @@ module ProjectsHelper
pagesAccessControlForced: ::Gitlab::Pages.access_control_is_forced?,
pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control'),
issuesHelpPath: help_page_path('user/project/issues/index'),
- membersPagePath: project_project_members_path(project)
+ membersPagePath: project_project_members_path(project),
+ environmentsHelpPath: help_page_path('ci/environments/index'),
+ featureFlagsHelpPath: help_page_path('operations/feature_flags'),
+ releasesHelpPath: help_page_path('user/project/releases/index')
}
end
@@ -437,7 +441,6 @@ module ProjectsHelper
def show_inactive_project_deletion_banner?(project)
return false unless project.present? && project.saved?
return false unless delete_inactive_projects?
- return false unless Feature.enabled?(:inactive_projects_deletion, project.root_namespace)
project.inactive?
end
@@ -452,9 +455,9 @@ module ProjectsHelper
def clusters_deprecation_alert_message
if has_active_license?
- s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of November 2022. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd} or reach out to GitLab support.')
+ s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd} or reach out to GitLab support.')
else
- s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of November 2022. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}.')
+ s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}.')
end
end
@@ -635,6 +638,7 @@ module ProjectsHelper
emailsDisabled: project.emails_disabled?,
metricsDashboardAccessLevel: feature.metrics_dashboard_access_level,
operationsAccessLevel: feature.operations_access_level,
+ monitorAccessLevel: feature.monitor_access_level,
showDefaultAwardEmojis: project.show_default_award_emojis?,
warnAboutPotentiallyUnwantedCharacters: project.warn_about_potentially_unwanted_characters?,
enforceAuthChecksOnUploads: project.enforce_auth_checks_on_uploads?,
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index dc53be330fe..b16235893ae 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -239,26 +239,26 @@ module SearchHelper
if can?(current_user, :download_code, @project)
result.concat([
- { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) },
- { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) }
- ])
+ { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) },
+ { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) }
+ ])
end
if can?(current_user, :read_repository_graphs, @project)
result.concat([
- { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) },
- { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) }
- ])
+ { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) },
+ { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) }
+ ])
end
result.concat([
- { category: "In this project", label: _("Issues"), url: project_issues_path(@project) },
- { category: "In this project", label: _("Merge requests"), url: project_merge_requests_path(@project) },
- { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) },
- { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) },
- { category: "In this project", label: _("Members"), url: project_project_members_path(@project) },
- { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) }
- ])
+ { category: "In this project", label: _("Issues"), url: project_issues_path(@project) },
+ { category: "In this project", label: _("Merge requests"), url: project_merge_requests_path(@project) },
+ { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) },
+ { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) },
+ { category: "In this project", label: _("Members"), url: project_project_members_path(@project) },
+ { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) }
+ ])
if can?(current_user, :read_feature_flag, @project)
result << { category: "In this project", label: _("Feature Flags"), url: project_feature_flags_path(@project) }
@@ -294,13 +294,13 @@ module SearchHelper
return [] unless issue && Ability.allowed?(current_user, :read_issue, issue)
[
- {
- category: 'In this project',
- id: issue.id,
- label: search_result_sanitize("#{issue.title} (#{issue.to_reference})"),
- url: issue_path(issue),
- avatar_url: issue.project.avatar_url || ''
- }
+ {
+ category: 'In this project',
+ id: issue.id,
+ label: search_result_sanitize("#{issue.title} (#{issue.to_reference})"),
+ url: issue_path(issue),
+ avatar_url: issue.project.avatar_url || ''
+ }
]
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 58f0af883f5..a711f36fe05 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -157,7 +157,9 @@ module SortingHelper
{
sort_value_name => sort_title_name,
sort_value_oldest_updated => sort_title_oldest_updated,
- sort_value_recently_updated => sort_title_recently_updated
+ sort_value_recently_updated => sort_title_recently_updated,
+ sort_value_version_desc => sort_title_version_desc,
+ sort_value_version_asc => sort_title_version_asc
}
end
diff --git a/app/helpers/sorting_titles_values_helper.rb b/app/helpers/sorting_titles_values_helper.rb
index 4dfa7689110..b49cb617d80 100644
--- a/app/helpers/sorting_titles_values_helper.rb
+++ b/app/helpers/sorting_titles_values_helper.rb
@@ -86,6 +86,14 @@ module SortingTitlesValuesHelper
s_('SortOptions|Name, descending')
end
+ def sort_title_version_desc
+ s_('SortOptions|Latest version')
+ end
+
+ def sort_title_version_asc
+ s_('SortOptions|Oldest version')
+ end
+
def sort_title_oldest_activity
s_('SortOptions|Oldest updated')
end
@@ -275,6 +283,14 @@ module SortingTitlesValuesHelper
'updated_asc'
end
+ def sort_value_version_asc
+ 'version_asc'
+ end
+
+ def sort_value_version_desc
+ 'version_desc'
+ end
+
def sort_value_popularity
'popularity'
end
diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb
index 9e516d726c1..a60143db739 100644
--- a/app/helpers/storage_helper.rb
+++ b/app/helpers/storage_helper.rb
@@ -23,119 +23,4 @@ module StorageHelper
_("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters
end
-
- def storage_enforcement_banner_info(context)
- root_ancestor = context.root_ancestor
-
- return unless should_show_storage_enforcement_banner?(context, current_user, root_ancestor)
-
- text_args = storage_enforcement_banner_text_args(root_ancestor, context)
-
- text_paragraph_2 = if root_ancestor.user_namespace?
- html_escape_once(s_("UsageQuota|The namespace is currently using %{strong_start}%{used_storage}%{strong_end} of namespace storage. " \
- "View and manage your usage from %{strong_start}User settings &gt; Usage quotas%{strong_end}. %{docs_link_start}Learn more%{link_end} " \
- "about how to reduce your storage.")).html_safe % text_args[:p2]
- else
- html_escape_once(s_("UsageQuota|The namespace is currently using %{strong_start}%{used_storage}%{strong_end} of namespace storage. " \
- "Group owners can view namespace storage usage and purchase more from %{strong_start}Group settings &gt; Usage quotas%{strong_end}. %{docs_link_start}Learn more.%{link_end}" \
- )).html_safe % text_args[:p2]
- end
-
- {
- text_paragraph_1: html_escape_once(s_("UsageQuota|Effective %{storage_enforcement_date}, namespace storage limits will apply " \
- "to the %{strong_start}%{namespace_name}%{strong_end} namespace. %{extra_message}" \
- "View the %{rollout_link_start}rollout schedule for this change%{link_end}.")).html_safe % text_args[:p1],
- text_paragraph_2: text_paragraph_2,
- text_paragraph_3: html_escape_once(s_("UsageQuota|See our %{faq_link_start}FAQ%{link_end} for more information.")).html_safe % text_args[:p3],
- variant: 'warning',
- namespace_id: root_ancestor.id,
- callouts_path: root_ancestor.user_namespace? ? callouts_path : group_callouts_path,
- callouts_feature_name: storage_enforcement_banner_user_callouts_feature_name(root_ancestor)
- }
- end
-
- private
-
- def should_show_storage_enforcement_banner?(context, current_user, root_ancestor)
- return false unless user_allowed_storage_enforcement_banner?(context, current_user, root_ancestor)
- return false if root_ancestor.paid?
- return false unless future_enforcement_date?(root_ancestor)
- return false if user_dismissed_storage_enforcement_banner?(root_ancestor)
-
- ::Feature.enabled?(:namespace_storage_limit_show_preenforcement_banner, root_ancestor)
- end
-
- def user_allowed_storage_enforcement_banner?(context, current_user, root_ancestor)
- return can?(current_user, :maintainer_access, context) unless context.respond_to?(:user_namespace?) && context.user_namespace?
-
- can?(current_user, :owner_access, context)
- end
-
- def storage_enforcement_banner_text_args(root_ancestor, context)
- strong_tags = {
- strong_start: "<strong>".html_safe,
- strong_end: "</strong>".html_safe
- }
-
- extra_message = if context.is_a?(Project)
- html_escape_once(s_("UsageQuota|The %{strong_start}%{context_name}%{strong_end} project will be affected by this. "))
- .html_safe % strong_tags.merge(context_name: context.name)
- elsif !context.root?
- html_escape_once(s_("UsageQuota|The %{strong_start}%{context_name}%{strong_end} group will be affected by this. "))
- .html_safe % strong_tags.merge(context_name: context.name)
- else
- ''
- end
-
- {
- p1: {
- storage_enforcement_date: root_ancestor.storage_enforcement_date,
- namespace_name: root_ancestor.name,
- extra_message: extra_message,
- rollout_link_start: '<a href="%{url}" >'.html_safe % { url: help_page_path('user/usage_quotas', anchor: 'namespace-storage-limit-enforcement-schedule') },
- link_end: "</a>".html_safe
- }.merge(strong_tags),
- p2: {
- used_storage: storage_counter(root_ancestor.root_storage_statistics&.storage_size || 0),
- docs_link_start: '<a href="%{url}" >'.html_safe % { url: help_page_path('user/usage_quotas', anchor: 'manage-your-storage-usage') },
- link_end: "</a>".html_safe
- }.merge(strong_tags),
- p3: {
- faq_link_start: '<a href="%{url}" >'.html_safe % { url: "#{Gitlab::Saas.about_pricing_url}faq-efficient-free-tier/#storage-limits-on-gitlab-saas-free-tier" },
- link_end: "</a>".html_safe
- }
- }
- end
-
- def storage_enforcement_banner_user_callouts_feature_name(namespace)
- "storage_enforcement_banner_#{storage_enforcement_banner_threshold(namespace)}_enforcement_threshold"
- end
-
- def storage_enforcement_banner_threshold(namespace)
- days_to_enforcement_date = (namespace.storage_enforcement_date - Date.today)
-
- 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
- end
-
- def user_dismissed_storage_enforcement_banner?(namespace)
- return false unless current_user
-
- 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
-
- def future_enforcement_date?(namespace)
- return true if ::Feature.enabled?(:namespace_storage_limit_bypass_date_check, namespace)
-
- namespace.storage_enforcement_date.present? && namespace.storage_enforcement_date >= Date.today
- end
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index a957c9ce9e0..3e5f63796b2 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -45,7 +45,11 @@ module SystemNoteHelper
'attention_requested' => 'user',
'attention_request_removed' => 'user',
'contact' => 'users',
- 'timeline_event' => 'clock'
+ 'timeline_event' => 'clock',
+ 'relate_to_child' => 'link',
+ 'unrelate_from_child' => 'link',
+ 'relate_to_parent' => 'link',
+ 'unrelate_from_parent' => 'link'
}.freeze
def system_note_icon_name(note)
diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb
index 39993bbfb44..11d09a79dcf 100644
--- a/app/helpers/timeboxes_helper.rb
+++ b/app/helpers/timeboxes_helper.rb
@@ -172,18 +172,19 @@ module TimeboxesHelper
def timebox_date_range(timebox)
if timebox.start_date && timebox.due_date
- "#{timebox.start_date.to_s(:medium)}–#{timebox.due_date.to_s(:medium)}"
+ s_("DateRange|%{start_date}–%{end_date}") % { start_date: l(timebox.start_date, format: Date::DATE_FORMATS[:medium]),
+ end_date: l(timebox.due_date, format: Date::DATE_FORMATS[:medium]) }
elsif timebox.due_date
if timebox.due_date.past?
- _("expired on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) }
+ _("expired on %{timebox_due_date}") % { timebox_due_date: l(timebox.due_date, format: Date::DATE_FORMATS[:medium]) }
else
- _("expires on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) }
+ _("expires on %{timebox_due_date}") % { timebox_due_date: l(timebox.due_date, format: Date::DATE_FORMATS[:medium]) }
end
elsif timebox.start_date
if timebox.start_date.past?
- _("started on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) }
+ _("started on %{timebox_start_date}") % { timebox_start_date: l(timebox.start_date, format: Date::DATE_FORMATS[:medium]) }
else
- _("starts on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) }
+ _("starts on %{timebox_start_date}") % { timebox_start_date: l(timebox.start_date, format: Date::DATE_FORMATS[:medium]) }
end
end
end
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 5977f51cab1..ecf29c41100 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -142,6 +142,16 @@ module TodosHelper
todos_filter_params.values.none?
end
+ def no_todos_messages
+ [
+ s_('Todos|Good job! Looks like you don\'t have anything left on your To-Do List'),
+ s_('Todos|Isn\'t an empty To-Do List beautiful?'),
+ s_('Todos|Give yourself a pat on the back!'),
+ s_('Todos|Nothing left to do. High five!'),
+ s_('Todos|Henceforth, you shall be known as "To-Do Destroyer"')
+ ]
+ end
+
def todos_filter_path(options = {})
without = options.delete(:without)
diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb
index 3dd6b3f4a80..d8baa185370 100644
--- a/app/helpers/users/callouts_helper.rb
+++ b/app/helpers/users/callouts_helper.rb
@@ -10,8 +10,10 @@ module Users
REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout'
UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout'
SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout'
+ MERGE_REQUEST_SETTINGS_MOVED_CALLOUT = 'merge_request_settings_moved_callout'
REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze
WEB_HOOK_DISABLED = 'web_hook_disabled'
+ ULTIMATE_FEATURE_REMOVAL_BANNER = 'ultimate_feature_removal_banner'
def show_gke_cluster_integration_callout?(project)
active_nav_link?(controller: sidebar_operations_paths) &&
@@ -71,18 +73,28 @@ module Users
last_failure = DateTime.parse(last_failure) if last_failure
- user_dismissed?(WEB_HOOK_DISABLED, last_failure, namespace: project.namespace)
+ user_dismissed?(WEB_HOOK_DISABLED, last_failure, project: project)
+ end
+
+ def show_merge_request_settings_callout?
+ !user_dismissed?(MERGE_REQUEST_SETTINGS_MOVED_CALLOUT)
+ end
+
+ def ultimate_feature_removal_banner_dismissed?(project)
+ return false unless project
+
+ user_dismissed?(ULTIMATE_FEATURE_REMOVAL_BANNER, project: project)
end
private
- def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, namespace: nil)
+ def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, project: nil)
return false unless current_user
query = { feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than }
- if namespace
- current_user.dismissed_callout_for_namespace?(namespace: namespace, **query)
+ if project
+ current_user.dismissed_callout_for_project?(project: project, **query)
else
current_user.dismissed_callout?(**query)
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index cae2addea9c..271fa47dd97 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -18,10 +18,11 @@ module UsersHelper
return _('We also use email for avatar detection if no avatar is uploaded.') unless user.unconfirmed_email.present?
confirmation_link = link_to _('Resend confirmation e-mail'), user_confirmation_path(user: { email: user.unconfirmed_email }), method: :post
-
- h(_('Please click the link in the confirmation email before continuing. It was sent to ')) +
- content_tag(:strong) { user.unconfirmed_email } + h('.') +
- content_tag(:p) { confirmation_link }
+ h(_('Please click the link in the confirmation email before continuing. It was sent to %{html_tag_strong_start}%{email}%{html_tag_strong_end}.')) % {
+ html_tag_strong_start: '<strong>'.html_safe,
+ html_tag_strong_end: '</strong>'.html_safe,
+ email: user.unconfirmed_email
+ } + content_tag(:p) { confirmation_link }
end
def profile_tabs
@@ -93,6 +94,7 @@ module UsersHelper
[].tap do |badges|
badges << blocked_user_badge(user) if user.blocked?
badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin?
+ badges << { text: s_('AdminUsers|Bot'), variant: 'muted' } if user.bot?
badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external?
badges << { text: s_("AdminUsers|It's you!"), variant: 'muted' } if current_user == user
badges << { text: s_("AdminUsers|Locked"), variant: 'warning' } if user.access_locked?
@@ -197,6 +199,9 @@ module UsersHelper
banned_badge = { text: s_('AdminUsers|Banned'), variant: 'danger' }
return banned_badge if user.banned?
+ ldap_blocked_badge = { text: s_('AdminUsers|LDAP Blocked'), variant: 'danger' }
+ return ldap_blocked_badge if user.ldap_blocked?
+
{ text: s_('AdminUsers|Blocked'), variant: 'danger' }
end
diff --git a/app/helpers/web_hooks/web_hooks_helper.rb b/app/helpers/web_hooks/web_hooks_helper.rb
index 95122750c2f..e95b90c69ef 100644
--- a/app/helpers/web_hooks/web_hooks_helper.rb
+++ b/app/helpers/web_hooks/web_hooks_helper.rb
@@ -5,6 +5,7 @@ module WebHooks
EXPIRY_TTL = 1.hour
def show_project_hook_failed_callout?(project:)
+ return false if project_hook_page?
return false unless current_user
return false unless Feature.enabled?(:webhooks_failed_callout, project)
return false unless Feature.enabled?(:web_hooks_disable_failed, project)
@@ -23,5 +24,9 @@ module WebHooks
ProjectHook.for_projects(project).disabled.exists?
end
end
+
+ def project_hook_page?
+ current_controller?('projects/hooks') || current_controller?('projects/hook_logs')
+ end
end
end
diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb
index 1fa85064c57..1bf7deec542 100644
--- a/app/mailers/abuse_report_mailer.rb
+++ b/app/mailers/abuse_report_mailer.rb
@@ -10,7 +10,7 @@ class AbuseReportMailer < ApplicationMailer
@abuse_report = AbuseReport.find(abuse_report_id)
- mail(
+ mail_with_locale(
to: Gitlab::CurrentSettings.abuse_notification_email,
subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse"
)
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index 94ed83a7d4a..bb8d20b8301 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -34,4 +34,23 @@ class ApplicationMailer < ActionMailer::Base
address.display_name = Gitlab.config.gitlab.email_display_name
address
end
+
+ def mail_with_locale(headers = {}, &block)
+ locale = recipient_locale headers
+
+ Gitlab::I18n.with_locale(locale) do
+ mail(headers, &block)
+ end
+ end
+
+ def recipient_locale(headers = {})
+ to = Array(headers[:to])
+ locale = I18n.locale
+ locale = preferred_language_by_email(to.first) if to.one?
+ locale
+ end
+
+ def preferred_language_by_email(email)
+ User.find_by_any_email(email)&.preferred_language || I18n.locale
+ end
end
diff --git a/app/mailers/email_rejection_mailer.rb b/app/mailers/email_rejection_mailer.rb
index 25721658285..f681aa67a77 100644
--- a/app/mailers/email_rejection_mailer.rb
+++ b/app/mailers/email_rejection_mailer.rb
@@ -22,6 +22,6 @@ class EmailRejectionMailer < ApplicationMailer
headers['Reply-To'] = @original_message.to.first if can_retry
- mail(headers)
+ mail_with_locale(headers)
end
end
diff --git a/app/mailers/emails/admin_notification.rb b/app/mailers/emails/admin_notification.rb
index 3766b4447d1..5c5497d8eb5 100644
--- a/app/mailers/emails/admin_notification.rb
+++ b/app/mailers/emails/admin_notification.rb
@@ -7,13 +7,13 @@ module Emails
email = user.notification_email_or_default
@unsubscribe_url = unsubscribe_url(email: Base64.urlsafe_encode64(email))
@body = body
- mail to: email, subject: subject
+ mail_with_locale to: email, subject: subject
end
def send_unsubscribed_notification(user_id)
user = User.find(user_id)
email = user.notification_email_or_default
- mail to: email, subject: "Unsubscribed from GitLab administrator notifications"
+ mail_with_locale to: email, subject: "Unsubscribed from GitLab administrator notifications"
end
end
end
diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb
index 07812a01202..3c9bf41c208 100644
--- a/app/mailers/emails/groups.rb
+++ b/app/mailers/emails/groups.rb
@@ -13,7 +13,7 @@ module Emails
def group_email(current_user, group, subj, errors: nil)
@group = group
@errors = errors
- mail(to: current_user.notification_email_for(@group), subject: subject(subj))
+ mail_with_locale(to: current_user.notification_email_for(@group), subject: subject(subj))
end
end
end
diff --git a/app/mailers/emails/identity_verification.rb b/app/mailers/emails/identity_verification.rb
index 2fc8cae06fe..e3089fdef9b 100644
--- a/app/mailers/emails/identity_verification.rb
+++ b/app/mailers/emails/identity_verification.rb
@@ -13,3 +13,5 @@ module Emails
end
end
end
+
+Emails::IdentityVerification.prepend_mod
diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb
index 1b46d4841b0..972c1da065a 100644
--- a/app/mailers/emails/in_product_marketing.rb
+++ b/app/mailers/emails/in_product_marketing.rb
@@ -31,7 +31,7 @@ module Emails
def mail_to(to:, subject:)
custom_headers = Gitlab.com? ? CUSTOM_HEADERS : {}
- mail(to: to, subject: subject, **custom_headers) do |format|
+ mail_with_locale(to: to, subject: subject, **custom_headers) do |format|
format.html do
@message.format = :html
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index c885e41671c..33c955f94ee 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -61,7 +61,7 @@ module Emails
Gitlab::Tracking.event(self.class.name, 'invite_email_sent', label: 'invite_email', property: member_id.to_s)
- mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format|
+ mail_with_locale(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format|
format.html { render layout: 'unknown_user_mailer' }
format.text { render layout: 'unknown_user_mailer' }
end
diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb
index 6c3dcf8746b..a6e9da18689 100644
--- a/app/mailers/emails/pages_domains.rb
+++ b/app/mailers/emails/pages_domains.rb
@@ -6,7 +6,7 @@ module Emails
@domain = domain
@project = domain.project
- mail(
+ mail_with_locale(
to: recipient.notification_email_for(@project.group),
subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled")
)
@@ -16,7 +16,7 @@ module Emails
@domain = domain
@project = domain.project
- mail(
+ mail_with_locale(
to: recipient.notification_email_for(@project.group),
subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled")
)
@@ -26,7 +26,7 @@ module Emails
@domain = domain
@project = domain.project
- mail(
+ mail_with_locale(
to: recipient.notification_email_for(@project.group),
subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'")
)
@@ -36,7 +36,7 @@ module Emails
@domain = domain
@project = domain.project
- mail(
+ mail_with_locale(
to: recipient.notification_email_for(@project.group),
subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'")
)
@@ -47,7 +47,7 @@ module Emails
@project = domain.project
subject_text = _("ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'") % { domain: domain.domain }
- mail(
+ mail_with_locale(
to: recipient.notification_email_for(@project.group),
subject: subject(subject_text)
)
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 81f082b9680..8fe471a48f2 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -6,7 +6,7 @@ module Emails
@current_user = @user = User.find(user_id)
@target_url = user_url(@user)
@token = token
- mail(to: @user.notification_email_or_default, subject: subject("Account was created for you"))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject("Account was created for you"))
end
def instance_access_request_email(user, recipient)
@@ -42,7 +42,7 @@ module Emails
@current_user = @user = @key.user
@target_url = user_url(@user)
- mail(to: @user.notification_email_or_default, subject: subject("SSH key was added to your account"))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject("SSH key was added to your account"))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -54,7 +54,7 @@ module Emails
@current_user = @user = @gpg_key.user
@target_url = user_url(@user)
- mail(to: @user.notification_email_or_default, subject: subject("GPG key was added to your account"))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject("GPG key was added to your account"))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -66,7 +66,7 @@ module Emails
@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")))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created")))
end
end
@@ -79,7 +79,7 @@ module Emails
@days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
end
end
@@ -90,7 +90,7 @@ module Emails
@target_url = profile_personal_access_tokens_url
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email_or_default, subject: subject(_("Your personal access token has expired")))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access token has expired")))
end
end
@@ -102,7 +102,7 @@ module Emails
@target_url = profile_keys_url
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired")))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired")))
end
end
@@ -114,7 +114,7 @@ module Emails
@target_url = profile_keys_url
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email_or_default, subject: subject(_("Your SSH key is expiring soon.")))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key is expiring soon.")))
end
end
@@ -137,7 +137,7 @@ module Emails
@user = user
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled")))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled")))
end
end
@@ -148,7 +148,7 @@ module Emails
@email = email
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email_or_default, subject: subject(_("New email address added")))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("New email address added")))
end
end
end
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index 5b8471abb0f..4bb624c27e9 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -7,28 +7,29 @@ module Emails
@project = Project.find project_id
@target_url = project_url(@project)
@old_path_with_namespace = old_path_with_namespace
- mail(to: @user.notification_email_for(@project.group),
- subject: subject("Project was moved"))
+ mail_with_locale(to: @user.notification_email_for(@project.group),
+ subject: subject("Project was moved"))
end
def project_was_exported_email(current_user, project)
@project = project
- mail(to: current_user.notification_email_for(project.group),
- subject: subject("Project was exported"))
+ mail_with_locale(to: current_user.notification_email_for(project.group),
+ subject: subject("Project was exported"))
end
def project_was_not_exported_email(current_user, project, errors)
@project = project
@errors = errors
- mail(to: current_user.notification_email_for(@project.group),
- subject: subject("Project export error"))
+ mail_with_locale(to: current_user.notification_email_for(@project.group),
+ subject: subject("Project export error"))
end
def repository_cleanup_success_email(project, user)
@project = project
@user = user
- mail(to: user.notification_email_for(project.group), subject: subject("Project cleanup has completed"))
+ mail_with_locale(to: user.notification_email_for(project.group),
+ subject: subject("Project cleanup has completed"))
end
def repository_cleanup_failure_email(project, user, error)
@@ -36,7 +37,7 @@ module Emails
@user = user
@error = error
- mail(to: user.notification_email_for(project.group), subject: subject("Project cleanup failure"))
+ mail_with_locale(to: user.notification_email_for(project.group), subject: subject("Project cleanup failure"))
end
def repository_push_email(project_id, opts = {})
@@ -51,9 +52,9 @@ module Emails
add_project_headers
headers['X-GitLab-Author'] = @message.author_username
- mail(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?),
- reply_to: @message.reply_to,
- subject: @message.subject)
+ mail_with_locale(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?),
+ reply_to: @message.reply_to,
+ subject: @message.subject)
end
def prometheus_alert_fired_email(project, user, alert)
@@ -65,7 +66,7 @@ module Emails
add_alert_headers
subject_text = "Alert: #{@alert.email_title}"
- mail(to: user.notification_email_for(@project.group), subject: subject(subject_text))
+ mail_with_locale(to: user.notification_email_for(@project.group), subject: subject(subject_text))
end
def inactive_project_deletion_warning_email(project, user, deletion_date)
diff --git a/app/mailers/emails/releases.rb b/app/mailers/emails/releases.rb
index 4875abafe8d..8fe93f59662 100644
--- a/app/mailers/emails/releases.rb
+++ b/app/mailers/emails/releases.rb
@@ -11,7 +11,7 @@ module Emails
)
@recipient = User.find(user_id)
- mail(
+ mail_with_locale(
to: @recipient.notification_email_for(@project.group),
subject: subject(release_email_subject)
)
diff --git a/app/mailers/emails/remote_mirrors.rb b/app/mailers/emails/remote_mirrors.rb
index 9cde53918b9..791ab7103b4 100644
--- a/app/mailers/emails/remote_mirrors.rb
+++ b/app/mailers/emails/remote_mirrors.rb
@@ -7,7 +7,7 @@ module Emails
@project = @remote_mirror.project
user = User.find(recipient_id)
- mail(to: user.notification_email_for(@project.group), subject: subject('Remote mirror update failed'))
+ mail_with_locale(to: user.notification_email_for(@project.group), subject: subject('Remote mirror update failed'))
end
end
end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index ed7681e595f..5a3fc70832c 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -38,11 +38,11 @@ class Notify < ApplicationMailer
helper InProductMarketingHelper
def test_email(recipient_email, subject, body)
- mail(to: recipient_email,
- subject: subject,
- body: body.html_safe,
- content_type: 'text/html'
- )
+ mail_with_locale(to: recipient_email,
+ subject: subject,
+ body: body.html_safe,
+ content_type: 'text/html'
+ )
end
# Splits "gitlab.corp.company.com" up into "gitlab.corp.company.com",
@@ -139,7 +139,7 @@ class Notify < ApplicationMailer
@reply_by_email = true
end
- mail(headers)
+ mail_with_locale(headers)
end
# `model` is used on EE code
@@ -225,7 +225,7 @@ class Notify < ApplicationMailer
end
def email_with_layout(to:, subject:, layout: 'mailer')
- mail(to: to, subject: subject) do |format|
+ mail_with_locale(to: to, subject: subject) do |format|
format.html { render layout: layout }
format.text { render layout: layout }
end
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index be8d96012cc..15b6fec3548 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -60,8 +60,12 @@ class NotifyPreview < ActionMailer::Preview
end
end
+ def user_cap_reached
+ Notify.user_cap_reached(user.id).message
+ end
+
def new_mention_in_merge_request_email
- Notify.new_mention_in_merge_request_email(user.id, issue.id, user.id).message
+ Notify.new_mention_in_merge_request_email(user.id, merge_request.id, user.id).message
end
def closed_issue_email
@@ -97,7 +101,7 @@ class NotifyPreview < ActionMailer::Preview
end
def closed_merge_request_email
- Notify.closed_merge_request_email(user.id, issue.id, user.id).message
+ Notify.closed_merge_request_email(user.id, merge_request.id, user.id).message
end
def merge_request_status_email
@@ -205,14 +209,6 @@ class NotifyPreview < ActionMailer::Preview
Notify.inactive_project_deletion_warning_email(project, user, '2022-04-22').message
end
- def user_auto_banned_instance_email
- ::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600).message
- end
-
- def user_auto_banned_namespace_email
- ::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600, group: group).message
- end
-
def verification_instructions_email
Notify.verification_instructions_email(user.id, token: '123456', expires_in: 60).message
end
@@ -220,7 +216,7 @@ class NotifyPreview < ActionMailer::Preview
private
def project
- @project ||= Project.find_by_full_path('gitlab-org/gitlab-test')
+ @project ||= Project.first
end
def issue
diff --git a/app/mailers/repository_check_mailer.rb b/app/mailers/repository_check_mailer.rb
index b8f990f26c8..17c36c19955 100644
--- a/app/mailers/repository_check_mailer.rb
+++ b/app/mailers/repository_check_mailer.rb
@@ -14,7 +14,7 @@ class RepositoryCheckMailer < ApplicationMailer
"#{failed_count} projects failed their last repository check"
end
- mail(
+ mail_with_locale(
to: User.admins.active.pluck(:email),
subject: "GitLab Admin | #{@message}"
)
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 9f634e70ff4..7dbc95c251b 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -83,21 +83,21 @@ class ActiveSession
is_impersonated: request.session[:impersonator_id].present?
)
- redis.pipelined do
- redis.setex(
+ redis.pipelined do |pipeline|
+ pipeline.setex(
key_name(user.id, session_private_id),
expiry,
active_user_session.dump
)
# Deprecated legacy format - temporary to support mixed deployments
- redis.setex(
+ pipeline.setex(
key_name_v1(user.id, session_private_id),
expiry,
Marshal.dump(active_user_session)
)
- redis.sadd(
+ pipeline.sadd(
lookup_key_name(user.id),
session_private_id
)
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 579f2c38ae6..edb9a2053b1 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -10,11 +10,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'
- ignore_column :pseudonymizer_enabled, remove_with: '15.1', remove_after: '2022-06-22'
- ignore_column :enforce_ssh_key_expiration, remove_with: '15.2', remove_after: '2022-07-22'
- ignore_column :enforce_pat_expiration, remove_with: '15.2', remove_after: '2022-07-22'
INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
@@ -221,6 +217,10 @@ class ApplicationSetting < ApplicationRecord
numericality: { only_integer: true, greater_than_or_equal_to: 0,
less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte }
+ validates :max_pages_custom_domains_per_project,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
validates :jobs_per_stage_page_size,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
@@ -406,6 +406,10 @@ class ApplicationSetting < ApplicationRecord
validates :invisible_captcha_enabled,
inclusion: { in: [true, false], message: _('must be a boolean value') }
+ validates :invitation_flow_enforcement,
+ allow_nil: false,
+ inclusion: { in: [true, false], message: _('must be a boolean value') }
+
Gitlab::SSHPublicKey.supported_types.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
@@ -621,6 +625,10 @@ class ApplicationSetting < ApplicationRecord
validates :inactive_projects_send_warning_email_after_months,
numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months }
+ validates :cube_api_base_url,
+ addressable_url: { allow_localhost: true, allow_local_network: false },
+ allow_blank: true
+
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -658,6 +666,7 @@ class ApplicationSetting < ApplicationRecord
attr_encrypted :database_grafana_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
attr_encrypted :arkose_labs_public_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
attr_encrypted :arkose_labs_private_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :cube_api_key, encryption_options_base_32_aes_256_gcm
validates :disable_feed_token,
inclusion: { in: [true, false], message: _('must be a boolean value') }
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 3fda8693a58..323d759510e 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -75,9 +75,9 @@ module Ci
def self.clone_accessors
%i[pipeline project ref tag options name
- allow_failure stage stage_id stage_idx
+ allow_failure stage stage_idx
yaml_variables when description needs_attributes
- scheduling_type].freeze
+ scheduling_type ci_stage partition_id].freeze
end
def inherit_status_from_downstream!(pipeline)
@@ -183,6 +183,10 @@ module Ci
false
end
+ def prevent_rollback_deployment?
+ false
+ end
+
def expanded_environment_name
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index bf8817e6e78..4e58f877217 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -11,7 +11,7 @@ module Ci
include Presentable
include Importable
include Ci::HasRef
- include HasDeploymentName
+ include Ci::TrackEnvironmentUsage
extend ::Gitlab::Utils::Override
@@ -34,7 +34,7 @@ module Ci
DEPLOYMENT_NAMES = %w[deploy release rollout].freeze
- has_one :deployment, as: :deployable, class_name: 'Deployment'
+ has_one :deployment, as: :deployable, class_name: 'Deployment', inverse_of: :deployable
has_one :pending_state, class_name: 'Ci::BuildPendingState', inverse_of: :build
has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id
has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id
@@ -194,7 +194,7 @@ module Ci
after_save :stick_build_if_status_changed
after_create unless: :importing? do |build|
- run_after_commit { build.feature_flagged_execute_hooks }
+ run_after_commit { build.execute_hooks }
end
class << self
@@ -214,10 +214,11 @@ module Ci
def clone_accessors
%i[pipeline project ref tag options name
- allow_failure stage stage_id stage_idx trigger_request
+ allow_failure stage stage_idx trigger_request
yaml_variables when environment coverage_regex
description tag_list protected needs_attributes
- job_variables_attributes resource_group scheduling_type].freeze
+ job_variables_attributes resource_group scheduling_type
+ ci_stage partition_id].freeze
end
end
@@ -285,7 +286,7 @@ module Ci
build.run_after_commit do
BuildQueueWorker.perform_async(id)
- build.feature_flagged_execute_hooks
+ build.execute_hooks
end
end
@@ -313,7 +314,7 @@ module Ci
build.run_after_commit do
build.ensure_persistent_ref
- build.feature_flagged_execute_hooks
+ build.execute_hooks
end
end
@@ -442,6 +443,15 @@ module Ci
manual? && starts_environment? && deployment&.blocked?
end
+ def prevent_rollback_deployment?
+ strong_memoize(:prevent_rollback_deployment) do
+ Feature.enabled?(:prevent_outdated_deployment_jobs, project) &&
+ starts_environment? &&
+ project.ci_forward_deployment_enabled? &&
+ deployment&.older_than_last_successful_deployment?
+ end
+ end
+
def schedulable?
self.when == 'delayed' && options[:start_in].present?
end
@@ -703,25 +713,7 @@ module Ci
end
def has_test_reports?
- job_artifacts.test_reports.exists?
- end
-
- def has_old_trace?
- old_trace.present?
- end
-
- def trace=(data)
- raise NotImplementedError
- end
-
- def old_trace
- read_attribute(:trace)
- end
-
- def erase_old_trace!
- return unless has_old_trace?
-
- update_column(:trace, nil)
+ job_artifacts.of_report_type(:test).exists?
end
def ensure_trace_metadata!
@@ -780,14 +772,6 @@ module Ci
pending? && !any_runners_online?
end
- def feature_flagged_execute_hooks
- if Feature.enabled?(:execute_build_hooks_inline, project)
- execute_hooks
- else
- BuildHooksWorker.perform_async(self)
- end
- end
-
def execute_hooks
return unless project
return if user&.blocked?
@@ -823,41 +807,6 @@ module Ci
end
end
- def erase_erasable_artifacts!
- if project.refreshing_build_artifacts_size?
- Gitlab::ProjectStatsRefreshConflictsLogger.warn_artifact_deletion_during_stats_refresh(
- method: 'Ci::Build#erase_erasable_artifacts!',
- project_id: project_id
- )
- end
-
- destroyed_artifacts = job_artifacts.erasable.destroy_all # rubocop: disable Cop/DestroyAll
-
- Gitlab::Ci::Artifacts::Logger.log_deleted(destroyed_artifacts, 'Ci::Build#erase_erasable_artifacts!')
-
- destroyed_artifacts
- end
-
- def erase(opts = {})
- return false unless erasable?
-
- if project.refreshing_build_artifacts_size?
- Gitlab::ProjectStatsRefreshConflictsLogger.warn_artifact_deletion_during_stats_refresh(
- method: 'Ci::Build#erase',
- project_id: project_id
- )
- end
-
- # TODO: We should use DestroyBatchService here
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/369132
- destroyed_artifacts = job_artifacts.destroy_all # rubocop: disable Cop/DestroyAll
-
- Gitlab::Ci::Artifacts::Logger.log_deleted(destroyed_artifacts, 'Ci::Build#erase')
-
- erase_trace!
- update_erased!(opts[:erased_by])
- end
-
def erasable?
complete? && (artifacts? || has_job_artifacts? || has_trace?)
end
@@ -1004,15 +953,11 @@ module Ci
end
def collect_test_reports!(test_reports)
- test_reports.get_suite(test_suite_name).tap do |test_suite|
- each_report(Ci::JobArtifact.file_types_for_report(:test)) do |file_type, blob|
- Gitlab::Ci::Parsers.fabricate!(file_type).parse!(
- blob,
- test_suite,
- job: self
- )
- end
+ each_report(Ci::JobArtifact.file_types_for_report(:test)) do |file_type, blob|
+ Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, test_reports, job: self)
end
+
+ test_reports
end
def collect_accessibility_reports!(accessibility_report)
@@ -1154,18 +1099,6 @@ 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
-
- def track_verify_usage
- Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_verify_environment_job', user_id) if user_id.present? && count_user_verification?
- end
-
- def count_user_verification?
- has_environment? && environment_action == 'verify'
- end
-
def each_report(report_types)
job_artifacts_for_types(report_types).each do |report_artifact|
report_artifact.each_blob do |blob|
@@ -1189,6 +1122,14 @@ module Ci
job_artifacts.map(&:file_type)
end
+ def test_suite_name
+ if matrix_build?
+ name
+ else
+ group_name
+ end
+ end
+
protected
def run_status_commit_hooks!
@@ -1199,14 +1140,6 @@ module Ci
private
- def test_suite_name
- if matrix_build?
- name
- else
- group_name
- end
- end
-
def matrix_build?
options.dig(:parallel, :matrix).present?
end
@@ -1245,14 +1178,6 @@ module Ci
job_artifacts.select { |artifact| artifact.file_type.in?(report_types) }
end
- def erase_trace!
- trace.erase!
- end
-
- def update_erased!(user = nil)
- self.update(erased_by: user, erased_at: Time.current, artifacts_expire_at: nil)
- end
-
def environment_url
options&.dig(:environment, :url) || persisted_environment&.external_url
end
@@ -1298,7 +1223,7 @@ module Ci
end
def observe_report_types
- return unless ::Gitlab.com? && Feature.enabled?(:report_artifact_build_completed_metrics_on_build_completion)
+ return unless ::Gitlab.com?
report_types = options&.dig(:artifacts, :reports)&.keys || []
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index 5fc21ba3f28..3bdf2f90acb 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -9,7 +9,6 @@ module Ci
include Presentable
include ChronicDurationAttribute
include Gitlab::Utils::StrongMemoize
- include IgnorableColumns
self.table_name = 'ci_builds_metadata'
@@ -39,8 +38,6 @@ module Ci
job_timeout_source: 4
}
- ignore_columns :runner_features, remove_with: '15.1', remove_after: '2022-05-22'
-
def update_timeout_state
timeout = timeout_with_highest_precedence
diff --git a/app/models/ci/freeze_period_status.rb b/app/models/ci/freeze_period_status.rb
index befa935e750..e810bb3f229 100644
--- a/app/models/ci/freeze_period_status.rb
+++ b/app/models/ci/freeze_period_status.rb
@@ -13,32 +13,16 @@ module Ci
end
def within_freeze_period?(period)
- # previous_freeze_end, ..., previous_freeze_start, ..., NOW, ..., next_freeze_end, ..., next_freeze_start
- # Current time is within a freeze period if
- # it falls between a previous freeze start and next freeze end
- start_freeze = Gitlab::Ci::CronParser.new(period.freeze_start, period.cron_timezone)
- end_freeze = Gitlab::Ci::CronParser.new(period.freeze_end, period.cron_timezone)
-
- previous_freeze_start = previous_time(start_freeze)
- previous_freeze_end = previous_time(end_freeze)
- next_freeze_start = next_time(start_freeze)
- next_freeze_end = next_time(end_freeze)
-
- previous_freeze_end < previous_freeze_start &&
- previous_freeze_start <= time_zone_now &&
- time_zone_now <= next_freeze_end &&
- next_freeze_end < next_freeze_start
- end
+ start_freeze_cron = Gitlab::Ci::CronParser.new(period.freeze_start, period.cron_timezone)
+ end_freeze_cron = Gitlab::Ci::CronParser.new(period.freeze_end, period.cron_timezone)
- private
+ start_freeze = start_freeze_cron.previous_time_from(time_zone_now)
+ end_freeze = end_freeze_cron.next_time_from(start_freeze)
- def previous_time(cron_parser)
- cron_parser.previous_time_from(time_zone_now)
+ start_freeze <= time_zone_now && time_zone_now <= end_freeze
end
- def next_time(cron_parser)
- cron_parser.next_time_from(time_zone_now)
- end
+ private
def time_zone_now
@time_zone_now ||= Time.zone.now
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 71d33f0bb63..922806a21c3 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -2,6 +2,7 @@
module Ci
class JobArtifact < Ci::ApplicationRecord
+ include Ci::Partitionable
include IgnorableColumns
include AfterCommitQueue
include ObjectStorage::BackgroundMove
@@ -9,6 +10,7 @@ module Ci
include UsageStatistics
include Sortable
include Artifactable
+ include Lockable
include FileStoreMounter
include EachBatch
include Gitlab::Utils::StrongMemoize
@@ -22,8 +24,7 @@ module Ci
accessibility: %w[accessibility],
coverage: %w[cobertura],
codequality: %w[codequality],
- terraform: %w[terraform],
- sbom: %w[cyclonedx]
+ terraform: %w[terraform]
}.freeze
DEFAULT_FILE_NAMES = {
@@ -54,7 +55,7 @@ module Ci
requirements: 'requirements.json',
coverage_fuzzing: 'gl-coverage-fuzzing.json',
api_fuzzing: 'gl-api-fuzzing-report.json',
- cyclonedx: 'gl-sbom.cdx.zip'
+ cyclonedx: 'gl-sbom.cdx.json'
}.freeze
INTERNAL_TYPES = {
@@ -72,6 +73,7 @@ module Ci
cobertura: :gzip,
cluster_applications: :gzip, # DEPRECATED: https://gitlab.com/gitlab-org/gitlab/-/issues/361094
lsif: :zip,
+ cyclonedx: :gzip,
# Security reports and license scanning reports are raw artifacts
# because they used to be fetched by the frontend, but this is not the case anymore.
@@ -94,8 +96,7 @@ module Ci
terraform: :raw,
requirements: :raw,
coverage_fuzzing: :raw,
- api_fuzzing: :raw,
- cyclonedx: :zip
+ api_fuzzing: :raw
}.freeze
DOWNLOADABLE_TYPES = %w[
@@ -134,14 +135,16 @@ module Ci
mount_file_store_uploader JobArtifactUploader, skip_store_file: true
+ before_save :set_size, if: :file_changed?
after_save :store_file_in_transaction!, unless: :store_after_commit?
after_commit :store_file_after_transaction!, on: [:create, :update], if: :store_after_commit?
+ validates :job, presence: true
validates :file_format, presence: true, unless: :trace?, on: :create
validate :validate_file_format!, unless: :trace?, on: :create
- before_save :set_size, if: :file_changed?
update_project_statistics project_statistics_name: :build_artifacts_size
+ partitionable scope: :job
scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) }
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
@@ -160,12 +163,6 @@ module Ci
where(file_type: types)
end
- REPORT_FILE_TYPES.each do |report_type, file_types|
- scope "#{report_type}_reports", -> do
- with_file_types(file_types)
- end
- end
-
scope :all_reports, -> do
with_file_types(REPORT_TYPES.keys.map(&:to_s))
end
@@ -229,25 +226,20 @@ module Ci
hashed_path: 2
}
- # `locked` will be populated from the source of truth on Ci::Pipeline
- # in order to clean up expired job artifacts in a performant way.
- # The values should be the same as `Ci::Pipeline.lockeds` with the
- # additional value of `unknown` to indicate rows that have not
- # yet been populated from the parent Ci::Pipeline
- enum locked: {
- unlocked: 0,
- artifacts_locked: 1,
- unknown: 2
- }, _prefix: :artifact
-
def validate_file_format!
unless TYPE_AND_FORMAT_PAIRS[self.file_type&.to_sym] == self.file_format&.to_sym
errors.add(:base, _('Invalid file format with specified file type'))
end
end
+ def self.of_report_type(report_type)
+ file_types = file_types_for_report(report_type)
+
+ with_file_types(file_types)
+ end
+
def self.file_types_for_report(report_type)
- REPORT_FILE_TYPES.fetch(report_type)
+ REPORT_FILE_TYPES.fetch(report_type) { raise ArgumentError, "Unrecognized report type: #{report_type}" }
end
def self.associated_file_types_for(file_type)
diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb
index 3a5765aa00c..26a49d6a730 100644
--- a/app/models/ci/job_token/scope.rb
+++ b/app/models/ci/job_token/scope.rb
@@ -30,10 +30,7 @@ module Ci
end
def all_projects
- Project.from_union([
- Project.id_in(source_project),
- Project.id_in(target_project_ids)
- ], remove_duplicates: false)
+ Project.from_union(target_projects, remove_duplicates: false)
end
private
@@ -41,6 +38,13 @@ module Ci
def target_project_ids
Ci::JobToken::ProjectScopeLink.from_project(source_project).pluck(:target_project_id)
end
+
+ def target_projects
+ [
+ Project.id_in(source_project),
+ Project.id_in(target_project_ids)
+ ]
+ end
end
end
end
diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb
index e8f08db597f..5ea51fbe0a7 100644
--- a/app/models/ci/namespace_mirror.rb
+++ b/app/models/ci/namespace_mirror.rb
@@ -43,20 +43,6 @@ module Ci
upsert({ namespace_id: event.namespace_id, traversal_ids: traversal_ids },
unique_by: :namespace_id)
-
- # It won't be necessary once we remove `sync_traversal_ids`.
- # More info: https://gitlab.com/gitlab-org/gitlab/-/issues/347541
- sync_children_namespaces!(event.namespace_id, traversal_ids)
- end
-
- private
-
- def sync_children_namespaces!(namespace_id, traversal_ids)
- by_group_and_descendants(namespace_id)
- .where.not(namespace_id: namespace_id)
- .update_all(
- "traversal_ids = ARRAY[#{sanitize_sql(traversal_ids.join(','))}]::int[] || traversal_ids[array_position(traversal_ids, #{sanitize_sql(namespace_id)}) + 1:]"
- )
end
end
end
diff --git a/app/models/ci/partition.rb b/app/models/ci/partition.rb
new file mode 100644
index 00000000000..d773038df01
--- /dev/null
+++ b/app/models/ci/partition.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Ci
+ class Partition < Ci::ApplicationRecord
+ end
+end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index a94330270e2..1e328c3c573 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -2,6 +2,7 @@
module Ci
class Pipeline < Ci::ApplicationRecord
+ include Ci::Partitionable
include Ci::HasStatus
include Importable
include AfterCommitQueue
@@ -31,7 +32,7 @@ module Ci
sha_attribute :source_sha
sha_attribute :target_sha
-
+ partitionable scope: ->(_) { Ci::Pipeline.current_partition_value }
# Ci::CreatePipelineService returns Ci::Pipeline so this is the only place
# where we can pass additional information from the service. This accessor
# is used for storing the processed metadata for linting purposes.
@@ -296,6 +297,12 @@ module Ci
end
end
+ after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
+ pipeline.run_after_commit do
+ ::Ci::JobArtifacts::TrackArtifactReportWorker.perform_async(pipeline.id)
+ end
+ end
+
after_transition any => ::Ci::Pipeline.stopped_statuses do |pipeline|
pipeline.run_after_commit do
pipeline.persistent_ref.delete
@@ -422,6 +429,10 @@ module Ci
end
def self.jobs_count_in_alive_pipelines
+ created_after(24.hours.ago).alive.joins(:statuses).count
+ end
+
+ def self.builds_count_in_alive_pipelines
created_after(24.hours.ago).alive.joins(:builds).count
end
@@ -472,8 +483,12 @@ module Ci
@auto_devops_pipelines_completed_total ||= Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines')
end
+ def self.current_partition_value
+ 100
+ end
+
def uses_needs?
- builds.where(scheduling_type: :dag).any?
+ processables.where(scheduling_type: :dag).any?
end
def stages_count
@@ -605,7 +620,7 @@ module Ci
if cascade_to_children
# cancel any bridges that could spin up new child pipelines
- cancel_jobs(bridges_in_self_and_descendants.cancelable, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id)
+ cancel_jobs(bridges_in_self_and_project_descendants.cancelable, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id)
cancel_children(auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id, execute_async: execute_async)
end
end
@@ -937,26 +952,26 @@ module Ci
).base_and_descendants.select(:id)
end
- def build_with_artifacts_in_self_and_descendants(name)
- builds_in_self_and_descendants
+ def build_with_artifacts_in_self_and_project_descendants(name)
+ builds_in_self_and_project_descendants
.ordered_by_pipeline # find job in hierarchical order
.with_downloadable_artifacts
.find_by_name(name)
end
- def builds_in_self_and_descendants
- Ci::Build.latest.where(pipeline: self_and_descendants)
+ def builds_in_self_and_project_descendants
+ Ci::Build.latest.where(pipeline: self_and_project_descendants)
end
- def bridges_in_self_and_descendants
- Ci::Bridge.latest.where(pipeline: self_and_descendants)
+ def bridges_in_self_and_project_descendants
+ Ci::Bridge.latest.where(pipeline: self_and_project_descendants)
end
- def environments_in_self_and_descendants(deployment_status: nil)
+ def environments_in_self_and_project_descendants(deployment_status: nil)
# We limit to 100 unique environments for application safety.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700
expanded_environment_names =
- builds_in_self_and_descendants.joins(:metadata)
+ builds_in_self_and_project_descendants.joins(:metadata)
.where.not('ci_builds_metadata.expanded_environment_name' => nil)
.distinct('ci_builds_metadata.expanded_environment_name')
.limit(100)
@@ -971,17 +986,22 @@ module Ci
end
# With multi-project and parent-child pipelines
- def all_pipelines_in_hierarchy
+ def self_and_downstreams
+ object_hierarchy.base_and_descendants
+ end
+
+ # With multi-project and parent-child pipelines
+ def upstream_and_all_downstreams
object_hierarchy.all_objects
end
# With only parent-child pipelines
- def self_and_ancestors
+ def self_and_project_ancestors
object_hierarchy(project_condition: :same).base_and_ancestors
end
# With only parent-child pipelines
- def self_and_descendants
+ def self_and_project_descendants
object_hierarchy(project_condition: :same).base_and_descendants
end
@@ -990,8 +1010,8 @@ module Ci
object_hierarchy(project_condition: :same).descendants
end
- def self_and_descendants_complete?
- self_and_descendants.all?(&:complete?)
+ def self_and_project_descendants_complete?
+ self_and_project_descendants.all?(&:complete?)
end
# Follow the parent-child relationships and return the top-level parent
@@ -1006,7 +1026,12 @@ module Ci
# Follow the upstream pipeline relationships, regardless of multi-project or
# parent-child, and return the top-level ancestor.
def upstream_root
- object_hierarchy.base_and_ancestors(hierarchy_order: :desc).first
+ @upstream_root ||= object_hierarchy.base_and_ancestors(hierarchy_order: :desc).first
+ end
+
+ # Applies to all parent-child and multi-project pipelines
+ def complete_hierarchy_count
+ upstream_root.self_and_downstreams.count
end
def bridge_triggered?
@@ -1052,11 +1077,11 @@ module Ci
end
def latest_test_report_builds
- latest_report_builds(Ci::JobArtifact.test_reports).preload(:project, :metadata)
+ latest_report_builds(Ci::JobArtifact.of_report_type(:test)).preload(:project, :metadata)
end
- def latest_report_builds_in_self_and_descendants(reports_scope = ::Ci::JobArtifact.all_reports)
- builds_in_self_and_descendants.with_artifacts(reports_scope)
+ def latest_report_builds_in_self_and_project_descendants(reports_scope = ::Ci::JobArtifact.all_reports)
+ builds_in_self_and_project_descendants.with_artifacts(reports_scope)
end
def builds_with_coverage
@@ -1068,10 +1093,14 @@ module Ci
end
def has_reports?(reports_scope)
+ latest_report_builds(reports_scope).exists?
+ end
+
+ def complete_and_has_reports?(reports_scope)
if Feature.enabled?(:mr_show_reports_immediately, project, type: :development)
latest_report_builds(reports_scope).exists?
else
- complete? && latest_report_builds(reports_scope).exists?
+ complete? && has_reports?(reports_scope)
end
end
@@ -1084,7 +1113,7 @@ module Ci
end
def can_generate_codequality_reports?
- has_reports?(Ci::JobArtifact.codequality_reports)
+ complete_and_has_reports?(Ci::JobArtifact.of_report_type(:codequality))
end
def test_report_summary
@@ -1103,7 +1132,7 @@ module Ci
def accessibility_reports
Gitlab::Ci::Reports::AccessibilityReports.new.tap do |accessibility_reports|
- latest_report_builds(Ci::JobArtifact.accessibility_reports).each do |build|
+ latest_report_builds(Ci::JobArtifact.of_report_type(:accessibility)).each do |build|
build.collect_accessibility_reports!(accessibility_reports)
end
end
@@ -1111,7 +1140,7 @@ module Ci
def codequality_reports
Gitlab::Ci::Reports::CodequalityReports.new.tap do |codequality_reports|
- latest_report_builds(Ci::JobArtifact.codequality_reports).each do |build|
+ latest_report_builds(Ci::JobArtifact.of_report_type(:codequality)).each do |build|
build.collect_codequality_reports!(codequality_reports)
end
end
@@ -1119,7 +1148,7 @@ module Ci
def terraform_reports
::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports|
- latest_report_builds(::Ci::JobArtifact.terraform_reports).each do |build|
+ latest_report_builds(::Ci::JobArtifact.of_report_type(:terraform)).each do |build|
build.collect_terraform_reports!(terraform_reports)
end
end
@@ -1307,7 +1336,7 @@ module Ci
def has_test_reports?
strong_memoize(:has_test_reports) do
- has_reports?(::Ci::JobArtifact.test_reports)
+ has_reports?(::Ci::JobArtifact.of_report_type(:test))
end
end
diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb
index cdc3d69f754..6d22a875aab 100644
--- a/app/models/ci/pipeline_artifact.rb
+++ b/app/models/ci/pipeline_artifact.rb
@@ -7,6 +7,7 @@ module Ci
include UpdateProjectStatistics
include Artifactable
include FileStoreMounter
+ include Lockable
include Presentable
FILE_SIZE_LIMIT = 10.megabytes.freeze
@@ -52,7 +53,7 @@ module Ci
find_by(file_type: file_type)
end
- def create_or_replace_for_pipeline!(pipeline:, file_type:, file:, size:)
+ def create_or_replace_for_pipeline!(pipeline:, file_type:, file:, size:, locked: :unknown)
transaction do
pipeline.pipeline_artifacts.find_by_file_type(file_type)&.destroy!
@@ -62,7 +63,8 @@ module Ci
size: size,
file: file,
file_format: REPORT_TYPES[file_type],
- expire_at: EXPIRATION_DATE.from_now
+ expire_at: EXPIRATION_DATE.from_now,
+ locked: locked
)
end
rescue ActiveRecord::ActiveRecordError => err
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
index 3dca77af051..6e4418bc360 100644
--- a/app/models/ci/pipeline_variable.rb
+++ b/app/models/ci/pipeline_variable.rb
@@ -2,13 +2,16 @@
module Ci
class PipelineVariable < Ci::ApplicationRecord
+ include Ci::Partitionable
include Ci::HasVariable
belongs_to :pipeline
+ partitionable scope: :pipeline
+
alias_attribute :secret_value, :value
- validates :key, presence: true
+ validates :key, :pipeline, presence: true
def hook_attrs
{ key: key, value: value }
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index a2ff49077be..09dc9d4bce1 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -3,6 +3,7 @@
module Ci
class Processable < ::CommitStatus
include Gitlab::Utils::StrongMemoize
+ include FromUnion
extend ::Gitlab::Utils::Override
has_one :resource, class_name: 'Ci::Resource', foreign_key: 'build_id', inverse_of: :processable
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 6c3754d84d0..28d9edcc135 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -8,15 +8,12 @@ module Ci
include ChronicDurationAttribute
include FromUnion
include TokenAuthenticatable
- include IgnorableColumns
include FeatureGate
include Gitlab::Utils::StrongMemoize
include TaggableQueries
include Presentable
include EachBatch
- ignore_column :semver, remove_with: '15.4', remove_after: '2022-08-22'
-
add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced?
enum access_level: {
@@ -351,6 +348,12 @@ module Ci
end
end
+ def owner_project
+ return unless project_type?
+
+ runner_projects.order(:id).first.project
+ end
+
def belongs_to_one_project?
runner_projects.count == 1
end
@@ -359,14 +362,6 @@ module Ci
runner_projects.limit(2).count(:all) > 1
end
- def assigned_to_group?
- runner_namespaces.any?
- end
-
- def assigned_to_project?
- runner_projects.any?
- end
-
def match_build_if_online?(build)
active? && online? && matches_build?(build)
end
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index f03d1e96a4b..46a9e3f6494 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -2,22 +2,31 @@
module Ci
class Stage < Ci::ApplicationRecord
+ include Ci::Partitionable
include Importable
include Ci::HasStatus
include Gitlab::OptimisticLocking
include Presentable
+ partitionable scope: :pipeline
+
enum status: Ci::HasStatus::STATUSES_ENUM
belongs_to :project
belongs_to :pipeline
- has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id
- has_many :latest_statuses, -> { ordered.latest }, class_name: 'CommitStatus', foreign_key: :stage_id
- has_many :retried_statuses, -> { ordered.retried }, class_name: 'CommitStatus', foreign_key: :stage_id
- has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id
- has_many :builds, foreign_key: :stage_id
- has_many :bridges, foreign_key: :stage_id
+ has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id, inverse_of: :ci_stage
+ has_many :latest_statuses, -> { ordered.latest },
+ class_name: 'CommitStatus',
+ foreign_key: :stage_id,
+ inverse_of: :ci_stage
+ has_many :retried_statuses, -> { ordered.retried },
+ class_name: 'CommitStatus',
+ foreign_key: :stage_id,
+ inverse_of: :ci_stage
+ has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id, inverse_of: :ci_stage
+ has_many :builds, foreign_key: :stage_id, inverse_of: :ci_stage
+ has_many :bridges, foreign_key: :stage_id, inverse_of: :ci_stage
scope :ordered, -> { order(position: :asc) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index c4db4754c52..1092b9c9564 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -6,6 +6,8 @@ module Ci
include Limitable
include IgnorableColumns
+ TRIGGER_TOKEN_PREFIX = 'glptt-'
+
ignore_column :ref, remove_with: '15.4', remove_after: '2022-08-22'
self.limit_name = 'pipeline_triggers'
@@ -22,7 +24,7 @@ module Ci
before_validation :set_default_values
def set_default_values
- self.token = SecureRandom.hex(15) if self.token.blank?
+ self.token = "#{TRIGGER_TOKEN_PREFIX}#{SecureRandom.hex(20)}" if self.token.blank?
end
def last_trigger_request
@@ -34,7 +36,7 @@ module Ci
end
def short_token
- token[0...4] if token.present?
+ token.delete_prefix(TRIGGER_TOKEN_PREFIX)[0...4] if token.present?
end
def can_access_project?
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 3a8c314efe4..27550616002 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -16,14 +16,10 @@ module Clusters
include ::Clusters::Concerns::ApplicationData
include AfterCommitQueue
include UsageStatistics
- include IgnorableColumns
default_value_for :ingress_type, :nginx
default_value_for :version, VERSION
- ignore_column :modsecurity_enabled, remove_with: '14.2', remove_after: '2021-07-22'
- ignore_column :modsecurity_mode, remove_with: '14.2', remove_after: '2021-07-22'
-
enum ingress_type: {
nginx: 1
}
diff --git a/app/models/commit.rb b/app/models/commit.rb
index bd60f02b532..54de45ebba7 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -133,6 +133,22 @@ class Commit
def parent_class
::Project
end
+
+ def build_from_sidekiq_hash(project, hash)
+ hash = hash.dup
+ date_suffix = '_date'
+
+ # When processing Sidekiq payloads various timestamps are stored as Strings.
+ # Commit in turn expects Time-like instances upon input, so we have to
+ # manually parse these values.
+ hash.each do |key, value|
+ if key.to_s.end_with?(date_suffix) && value.is_a?(String)
+ hash[key] = Time.zone.parse(value)
+ end
+ end
+
+ from_hash(hash, project)
+ end
end
attr_accessor :raw
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index afe4927ee73..05a258e6e26 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class CommitStatus < Ci::ApplicationRecord
+ include Ci::Partitionable
include Ci::HasStatus
include Importable
include AfterCommitQueue
@@ -11,13 +12,14 @@ class CommitStatus < Ci::ApplicationRecord
include IgnorableColumns
self.table_name = 'ci_builds'
-
- ignore_column :token, remove_with: '15.4', remove_after: '2022-08-22'
+ partitionable scope: :pipeline
+ ignore_column :trace, remove_with: '15.6', remove_after: '2022-10-22'
belongs_to :user
belongs_to :project
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
+ belongs_to :ci_stage, class_name: 'Ci::Stage', foreign_key: :stage_id
has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build
@@ -318,6 +320,10 @@ class CommitStatus < Ci::ApplicationRecord
Gitlab::EtagCaching::Store.new.touch(job_path)
end
+ def stage_name
+ ci_stage&.name
+ end
+
private
def unrecoverable_failure?
diff --git a/app/models/concerns/approvable_base.rb b/app/models/concerns/approvable.rb
index 8240f9bd6ea..1566c53217d 100644
--- a/app/models/concerns/approvable_base.rb
+++ b/app/models/concerns/approvable.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module ApprovableBase
+module Approvable
extend ActiveSupport::Concern
include FromUnion
@@ -27,12 +27,11 @@ module ApprovableBase
scope :not_approved_by_users_with_usernames, -> (usernames) do
users = User.where(username: usernames).select(:id)
- self_table = self.arel_table
app_table = Approval.arel_table
where(
Approval.where(approvals: { user_id: users })
- .where(app_table[:merge_request_id].eq(self_table[:id]))
+ .where(app_table[:merge_request_id].eq(arel_table[:id]))
.select('true')
.arel.exists.not
)
@@ -48,7 +47,7 @@ module ApprovableBase
def approved_by?(user)
return false unless user
- approved_by_users.include?(user)
+ approvals.where(user: user).any?
end
def can_be_approved_by?(user)
@@ -59,3 +58,5 @@ module ApprovableBase
user && approved_by?(user) && user.can?(:approve_merge_request, self)
end
end
+
+Approvable.prepend_mod
diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb
index ee8e98ec1bf..3fdbd6a8789 100644
--- a/app/models/concerns/ci/artifactable.rb
+++ b/app/models/concerns/ci/artifactable.rb
@@ -10,8 +10,17 @@ module Ci
STORE_COLUMN = :file_store
NotSupportedAdapterError = Class.new(StandardError)
FILE_FORMAT_ADAPTERS = {
+ # While zip is a streamable file format, performing streaming
+ # reads requires that each entry in the zip has certain headers
+ # present at the front of the entry. These headers are OPTIONAL
+ # according to the file format specification. GitLab Runner uses
+ # Go's `archive/zip` to create zip archives, which does not include
+ # these headers. Go maintainers have expressed that they don't intend
+ # to support them: https://github.com/golang/go/issues/23301#issuecomment-363240781
+ #
+ # If you need GitLab to be able to read Artifactables, store them in
+ # raw or gzip format instead of zip.
gzip: Gitlab::Ci::Build::Artifacts::Adapters::GzipStream,
- zip: Gitlab::Ci::Build::Artifacts::Adapters::ZipStream,
raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream
}.freeze
diff --git a/app/models/concerns/ci/has_deployment_name.rb b/app/models/concerns/ci/has_deployment_name.rb
deleted file mode 100644
index 887653e846e..00000000000
--- a/app/models/concerns/ci/has_deployment_name.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- module HasDeploymentName
- extend ActiveSupport::Concern
-
- def count_user_deployment?
- 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/lockable.rb b/app/models/concerns/ci/lockable.rb
new file mode 100644
index 00000000000..31ba93775e2
--- /dev/null
+++ b/app/models/concerns/ci/lockable.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Ci
+ module Lockable
+ extend ActiveSupport::Concern
+
+ included do
+ # `locked` will be populated from the source of truth on Ci::Pipeline
+ # in order to clean up expired job artifacts in a performant way.
+ # The values should be the same as `Ci::Pipeline.lockeds` with the
+ # additional value of `unknown` to indicate rows that have not
+ # yet been populated from the parent Ci::Pipeline
+ enum locked: {
+ unlocked: 0,
+ artifacts_locked: 1,
+ unknown: 2
+ }, _prefix: :artifact
+ end
+ end
+end
diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb
index 8c3a05c23f0..71b26b70bbf 100644
--- a/app/models/concerns/ci/metadatable.rb
+++ b/app/models/concerns/ci/metadatable.rb
@@ -34,7 +34,7 @@ module Ci
end
def ensure_metadata
- metadata || build_metadata(project: project)
+ metadata || build_metadata(project: project, partition_id: partition_id)
end
def degenerated?
diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb
new file mode 100644
index 00000000000..710ee1ba64f
--- /dev/null
+++ b/app/models/concerns/ci/partitionable.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Ci
+ ##
+ # This module implements a way to set the `partion_id` value on a dependent
+ # resource from a parent record.
+ # Usage:
+ #
+ # class PipelineVariable < Ci::ApplicationRecord
+ # include Ci::Partitionable
+ #
+ # belongs_to :pipeline
+ # partitionable scope: :pipeline
+ # # Or
+ # partitionable scope: ->(record) { record.partition_value }
+ #
+ #
+ module Partitionable
+ extend ActiveSupport::Concern
+ include ::Gitlab::Utils::StrongMemoize
+
+ included do
+ before_validation :set_partition_id, on: :create
+ validates :partition_id, presence: true
+
+ def set_partition_id
+ return if partition_id_changed? && partition_id.present?
+ return unless partition_scope_value
+
+ self.partition_id = partition_scope_value
+ end
+ end
+
+ class_methods do
+ private
+
+ def partitionable(scope:)
+ define_method(:partition_scope_value) do
+ strong_memoize(:partition_scope_value) do
+ record = scope.to_proc.call(self)
+ record.respond_to?(:partition_id) ? record.partition_id : record
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/ci/track_environment_usage.rb b/app/models/concerns/ci/track_environment_usage.rb
new file mode 100644
index 00000000000..45d9cdeeb59
--- /dev/null
+++ b/app/models/concerns/ci/track_environment_usage.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+ module TrackEnvironmentUsage
+ extend ActiveSupport::Concern
+
+ def track_deployment_usage
+ return unless user_id.present? && count_user_deployment?
+
+ Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_deployment_job', user_id)
+ end
+
+ def track_verify_environment_usage
+ return unless user_id.present? && verifies_environment?
+
+ Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_verify_environment_job', user_id)
+ end
+
+ def verifies_environment?
+ has_environment? && environment_action == 'verify'
+ end
+
+ def count_user_deployment?
+ 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/counter_attribute.rb b/app/models/concerns/counter_attribute.rb
index 65cf3246d11..64d178b7507 100644
--- a/app/models/concerns/counter_attribute.rb
+++ b/app/models/concerns/counter_attribute.rb
@@ -65,6 +65,10 @@ module CounterAttribute
def counter_attribute_after_flush(&callback)
after_flush_callbacks << callback
end
+
+ def counter_attribute_enabled?(attribute)
+ counter_attributes.include?(attribute)
+ end
end
# This method must only be called by FlushCounterIncrementsWorker
@@ -103,16 +107,14 @@ module CounterAttribute
end
def delayed_increment_counter(attribute, increment)
+ raise ArgumentError, "#{attribute} is not a counter attribute" unless counter_attribute_enabled?(attribute)
+
return if increment == 0
run_after_commit_or_now do
- if counter_attribute_enabled?(attribute)
- increment_counter(attribute, increment)
+ increment_counter(attribute, increment)
- FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute)
- else
- legacy_increment!(attribute, increment)
- end
+ FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute)
end
true
@@ -157,7 +159,7 @@ module CounterAttribute
end
def counter_attribute_enabled?(attribute)
- self.class.counter_attributes.include?(attribute)
+ self.class.counter_attribute_enabled?(attribute)
end
private
@@ -168,10 +170,6 @@ module CounterAttribute
end
end
- def legacy_increment!(attribute, increment)
- increment!(attribute, increment)
- end
-
def unsafe_update_counters(id, increments)
self.class.update_counters(id, increments)
end
diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb
index ecb120d8013..9de2da5aac3 100644
--- a/app/models/concerns/enums/ci/commit_status.rb
+++ b/app/models/concerns/enums/ci/commit_status.rb
@@ -19,7 +19,7 @@ module Enums
unmet_prerequisites: 10,
scheduler_failure: 11,
data_integrity_failure: 12,
- forward_deployment_failure: 13,
+ forward_deployment_failure: 13, # Deprecated in favor of failed_outdated_deployment_job.
user_blocked: 14,
project_deleted: 15,
ci_quota_exceeded: 16,
@@ -29,6 +29,7 @@ module Enums
builds_disabled: 20,
environment_creation_failure: 21,
deployment_rejected: 22,
+ failed_outdated_deployment_job: 23,
protected_environment_failure: 1_000,
insufficient_bridge_permissions: 1_001,
downstream_bridge_project_not_found: 1_002,
@@ -39,7 +40,8 @@ module Enums
downstream_pipeline_creation_failed: 1_007,
secrets_provider_not_found: 1_008,
reached_max_descendant_pipelines_depth: 1_009,
- ip_restriction_failure: 1_010
+ ip_restriction_failure: 1_010,
+ reached_max_pipeline_hierarchy_size: 1_011
}
end
end
diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb
index 71c86bab136..a8227363a22 100644
--- a/app/models/concerns/enums/internal_id.rb
+++ b/app/models/concerns/enums/internal_id.rb
@@ -16,7 +16,8 @@ module Enums
alert_management_alerts: 8,
sprints: 9, # iterations
design_management_designs: 10,
- incident_management_oncall_schedules: 11
+ incident_management_oncall_schedules: 11,
+ ml_experiments: 12
}
end
end
diff --git a/app/models/concerns/from_set_operator.rb b/app/models/concerns/from_set_operator.rb
index ce3a83e9fa1..56b788eb1ab 100644
--- a/app/models/concerns/from_set_operator.rb
+++ b/app/models/concerns/from_set_operator.rb
@@ -10,7 +10,9 @@ module FromSetOperator
raise "Trying to redefine method '#{method(method_name)}'" if methods.include?(method_name)
- define_method(method_name) do |members, remove_duplicates: true, remove_order: true, alias_as: table_name|
+ define_method(method_name) do |*members, remove_duplicates: true, remove_order: true, alias_as: table_name|
+ members = flatten_ar_array(members)
+
operator_sql =
if members.any?
operator.new(members, remove_duplicates: remove_duplicates, remove_order: remove_order).to_sql
@@ -20,5 +22,26 @@ module FromSetOperator
from(Arel.sql("(#{operator_sql}) #{alias_as}"))
end
+
+ # Array#flatten with ActiveRecord::Relation items will load the ActiveRecord::Relation.
+ # Therefore we need to roll our own flatten method.
+ unless method_defined?(:flatten_ar_array) # rubocop:disable Style/GuardClause
+ define_method :flatten_ar_array do |ary|
+ arrays = ary.dup
+ result = []
+
+ until arrays.empty?
+ item = arrays.shift
+ if item.is_a?(Array)
+ arrays.concat(item.dup)
+ else
+ result.push(item)
+ end
+ end
+
+ result
+ end
+ private :flatten_ar_array
+ end
end
end
diff --git a/app/models/concerns/integrations/slack_mattermost_notifier.rb b/app/models/concerns/integrations/slack_mattermost_notifier.rb
index 142e62bb501..1ecddc015ab 100644
--- a/app/models/concerns/integrations/slack_mattermost_notifier.rb
+++ b/app/models/concerns/integrations/slack_mattermost_notifier.rb
@@ -21,13 +21,13 @@ module Integrations
)
responses.each do |response|
- unless response.success?
- log_error('SlackMattermostNotifier HTTP error response',
- request_host: response.request.uri.host,
- response_code: response.code,
- response_body: response.body
- )
- end
+ next if response.success?
+
+ log_error('SlackMattermostNotifier HTTP error response',
+ request_host: response.request.uri.host,
+ response_code: response.code,
+ response_body: response.body
+ )
end
end
diff --git a/app/models/concerns/merge_request_reviewer_state.rb b/app/models/concerns/merge_request_reviewer_state.rb
index 18ec1c253e1..412b1da55da 100644
--- a/app/models/concerns/merge_request_reviewer_state.rb
+++ b/app/models/concerns/merge_request_reviewer_state.rb
@@ -6,20 +6,11 @@ module MergeRequestReviewerState
included do
enum state: {
unreviewed: 0,
- reviewed: 1,
- attention_requested: 2
+ reviewed: 1
}
validates :state,
presence: true,
inclusion: { in: self.states.keys }
-
- belongs_to :updated_state_by, class_name: 'User', foreign_key: :updated_state_by_user_id
-
- 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
index 813827478da..335fcec2611 100644
--- a/app/models/concerns/pg_full_text_searchable.rb
+++ b/app/models/concerns/pg_full_text_searchable.rb
@@ -108,6 +108,7 @@ module PgFullTextSearchable
# This fixes an inconsistency with how to_tsvector and websearch_to_tsquery process URLs
# See https://gitlab.com/gitlab-org/gitlab/-/issues/354784#note_905431920
search_term = remove_url_scheme(search_term)
+ search_term = ActiveSupport::Inflector.transliterate(search_term)
joins(:search_data).where(
Arel::Nodes::InfixOperation.new(
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index 7613691bc2e..2976b6f02a7 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -86,6 +86,10 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:operations_access_level, value)
end
+ def monitor_access_level=(value)
+ write_feature_attribute_string(:monitor_access_level, value)
+ end
+
def security_and_compliance_access_level=(value)
write_feature_attribute_string(:security_and_compliance_access_level, value)
end
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index 65fb62a814f..eccb004b503 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -43,6 +43,33 @@ module Sortable
}
end
+ def build_keyset_order_on_joined_column(scope:, attribute_name:, column:, direction:, nullable:)
+ reversed_direction = direction == :asc ? :desc : :asc
+
+ # rubocop: disable GitlabSecurity/PublicSend
+ order = ::Gitlab::Pagination::Keyset::Order.build(
+ [
+ ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: attribute_name,
+ column_expression: column,
+ order_expression: column.send(direction).send(nullable),
+ reversed_order_expression: column.send(reversed_direction).send(nullable),
+ order_direction: direction,
+ distinct: false,
+ add_to_projections: true,
+ nullable: nullable
+ ),
+ ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: arel_table['id'].desc
+ )
+ ]
+ )
+ # rubocop: enable GitlabSecurity/PublicSend
+
+ order.apply_cursor_conditions(scope).reorder(order)
+ end
+
private
def highest_label_priority(target_type_column: nil, target_type: nil, target_column:, project_column:, excluded_labels: [])
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index e10452c1081..14520b2da26 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -263,10 +263,10 @@ class ContainerRepository < ApplicationRecord
.with_migration_import_started_at_nil_or_before(before_timestamp)
union = ::Gitlab::SQL::Union.new([
- stale_pre_importing,
- stale_pre_import_done,
- stale_importing
- ])
+ stale_pre_importing,
+ stale_pre_import_done,
+ stale_importing
+ ])
from("(#{union.to_sql}) #{ContainerRepository.table_name}")
end
@@ -598,6 +598,7 @@ class ContainerRepository < ApplicationRecord
tags_response_body.map do |raw_tag|
tag = ContainerRegistry::Tag.new(self, raw_tag['name'])
tag.force_created_at_from_iso8601(raw_tag['created_at'])
+ tag.updated_at = raw_tag['updated_at']
tag
end
end
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index f6455da890b..16c741d340f 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -79,22 +79,23 @@ class CustomerRelations::Contact < ApplicationRecord
end
def self.sort_by_name
- order(Gitlab::Pagination::Keyset::Order.build([
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'last_name',
- order_expression: arel_table[:last_name].asc,
- distinct: false
- ),
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'first_name',
- order_expression: arel_table[:first_name].asc,
- distinct: false
- ),
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'id',
- order_expression: arel_table[:id].asc
- )
- ]))
+ order(Gitlab::Pagination::Keyset::Order.build(
+ [
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'last_name',
+ order_expression: arel_table[:last_name].asc,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'first_name',
+ order_expression: arel_table[:first_name].asc,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: arel_table[:id].asc
+ )
+ ]))
end
def self.find_ids_by_emails(group, emails)
@@ -117,22 +118,14 @@ class CustomerRelations::Contact < ApplicationRecord
JOIN #{table_name} AS new_contacts ON new_contacts.group_id = :old_group_id AND LOWER(new_contacts.email) = LOWER(existing_contacts.email)
WHERE existing_contacts.group_id = :new_group_id AND contact_id = existing_contacts.id
SQL
- connection.execute(sanitize_sql([
- update_query,
- old_group_id: group.root_ancestor.id,
- new_group_id: group.id
- ]))
+ connection.execute(sanitize_sql([update_query, old_group_id: group.root_ancestor.id, new_group_id: group.id]))
dupes_query = <<~SQL
DELETE FROM #{table_name} AS existing_contacts
USING #{table_name} AS new_contacts
WHERE existing_contacts.group_id = :new_group_id AND new_contacts.group_id = :old_group_id AND LOWER(new_contacts.email) = LOWER(existing_contacts.email)
SQL
- connection.execute(sanitize_sql([
- dupes_query,
- old_group_id: group.root_ancestor.id,
- new_group_id: group.id
- ]))
+ connection.execute(sanitize_sql([dupes_query, old_group_id: group.root_ancestor.id, new_group_id: group.id]))
where(group: group).update_all(group_id: group.root_ancestor.id)
end
diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb
index 705e84250c9..5eda9b4bf15 100644
--- a/app/models/customer_relations/organization.rb
+++ b/app/models/customer_relations/organization.rb
@@ -23,6 +23,9 @@ class CustomerRelations::Organization < ApplicationRecord
validates :description, length: { maximum: 1024 }
validate :validate_root_group
+ scope :order_scope_asc, ->(field) { order(arel_table[field].asc.nulls_last) }
+ scope :order_scope_desc, ->(field) { order(arel_table[field].desc.nulls_last) }
+
# Searches for organizations with a matching name or description.
#
# This method uses ILIKE on PostgreSQL
@@ -38,6 +41,14 @@ class CustomerRelations::Organization < ApplicationRecord
where(state: state)
end
+ def self.sort_by_field(field, direction)
+ if direction == :asc
+ order_scope_asc(field)
+ else
+ order_scope_desc(field)
+ end
+ end
+
def self.sort_by_name
order(name: :asc)
end
@@ -55,28 +66,30 @@ class CustomerRelations::Organization < ApplicationRecord
JOIN #{table_name} AS new_organizations ON new_organizations.group_id = :old_group_id AND LOWER(new_organizations.name) = LOWER(existing_organizations.name)
WHERE existing_organizations.group_id = :new_group_id AND organization_id = existing_organizations.id
SQL
- connection.execute(sanitize_sql([
- update_query,
- old_group_id: group.root_ancestor.id,
- new_group_id: group.id
- ]))
+ connection.execute(sanitize_sql([update_query, old_group_id: group.root_ancestor.id, new_group_id: group.id]))
dupes_query = <<~SQL
DELETE FROM #{table_name} AS existing_organizations
USING #{table_name} AS new_organizations
WHERE existing_organizations.group_id = :new_group_id AND new_organizations.group_id = :old_group_id AND LOWER(new_organizations.name) = LOWER(existing_organizations.name)
SQL
- connection.execute(sanitize_sql([
- dupes_query,
- old_group_id: group.root_ancestor.id,
- new_group_id: group.id
- ]))
+ connection.execute(sanitize_sql([dupes_query, old_group_id: group.root_ancestor.id, new_group_id: group.id]))
where(group: group).update_all(group_id: group.root_ancestor.id)
end
+ def self.counts_by_state
+ default_state_counts.merge(group(:state).count)
+ end
+
private
+ def self.default_state_counts
+ states.keys.each_with_object({}) do |key, memo|
+ memo[key] = 0
+ end
+ end
+
def validate_root_group
return if group&.root?
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index a3213a59bed..dafcbc593be 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -18,7 +18,7 @@ class Deployment < ApplicationRecord
belongs_to :environment, optional: false
belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true
belongs_to :user
- belongs_to :deployable, polymorphic: true, optional: true # rubocop:disable Cop/PolymorphicAssociations
+ belongs_to :deployable, polymorphic: true, optional: true, inverse_of: :deployment # rubocop:disable Cop/PolymorphicAssociations
has_many :deployment_merge_requests
has_many :merge_requests,
@@ -36,6 +36,7 @@ class Deployment < ApplicationRecord
delegate :name, to: :environment, prefix: true
delegate :kubernetes_namespace, to: :deployment_cluster, allow_nil: true
+ scope :for_iid, -> (project, iid) { where(project: project, iid: iid) }
scope :for_environment, -> (environment) { where(environment_id: environment) }
scope :for_environment_name, -> (project, name) do
where('deployments.environment_id = (?)',
@@ -58,9 +59,11 @@ class Deployment < ApplicationRecord
scope :finished_before, ->(date) { where('finished_at < ?', date) }
scope :ordered, -> { order(finished_at: :desc) }
+ scope :ordered_as_upcoming, -> { order(id: :desc) }
VISIBLE_STATUSES = %i[running success failed canceled blocked].freeze
FINISHED_STATUSES = %i[success failed canceled].freeze
+ UPCOMING_STATUSES = %i[created blocked running].freeze
state_machine :status, initial: :created do
event :run do
@@ -220,6 +223,10 @@ class Deployment < ApplicationRecord
Ci::Build.where(id: deployable_ids)
end
+ def build
+ deployable if deployable.is_a?(::Ci::Build)
+ end
+
class << self
##
# FastDestroyAll concerns
@@ -310,6 +317,16 @@ class Deployment < ApplicationRecord
project.repository.ancestor?(ancestor_sha, sha)
end
+ def older_than_last_successful_deployment?
+ last_deployment_id = environment.last_deployment&.id
+
+ return false unless last_deployment_id.present?
+
+ return false if self.id == last_deployment_id
+
+ self.id < last_deployment_id
+ end
+
def update_merge_request_metrics!
return unless environment.production? && success?
@@ -436,6 +453,12 @@ class Deployment < ApplicationRecord
deployable.environment_tier_from_options
end
+ # default tag limit is 100, 0 means no limit
+ def tags(limit: 100)
+ project.repository.tag_names_contains(sha, limit: limit)
+ end
+ strong_memoize_attr :tags
+
private
def update_status!(status)
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 1950431446b..4b98cd02e3b 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -13,6 +13,7 @@ class Environment < ApplicationRecord
self.reactive_cache_work_type = :external_dependency
belongs_to :project, optional: false
+ belongs_to :merge_request, optional: true
use_fast_destroy :all_deployments
nullify_if_blank :external_url
@@ -30,6 +31,16 @@ class Environment < ApplicationRecord
has_one :last_deployment, -> { success.ordered }, class_name: 'Deployment', inverse_of: :environment
has_one :last_visible_deployment, -> { visible.order(id: :desc) }, inverse_of: :environment, class_name: 'Deployment'
+ Deployment::FINISHED_STATUSES.each do |status|
+ has_one :"last_#{status}_deployment", -> { where(status: status).ordered },
+ class_name: 'Deployment', inverse_of: :environment
+ end
+
+ Deployment::UPCOMING_STATUSES.each do |status|
+ has_one :"last_#{status}_deployment", -> { where(status: status).ordered_as_upcoming },
+ class_name: 'Deployment', inverse_of: :environment
+ end
+
has_one :upcoming_deployment, -> { upcoming.order(id: :desc) }, class_name: 'Deployment', inverse_of: :environment
has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment
@@ -58,6 +69,7 @@ class Environment < ApplicationRecord
allow_nil: true
validate :safe_external_url
+ validate :merge_request_not_changed
delegate :manual_actions, :other_manual_actions, to: :last_deployment, allow_nil: true
delegate :auto_rollback_enabled?, to: :project
@@ -84,11 +96,12 @@ class Environment < ApplicationRecord
# Search environments which have names like the given query.
# Do not set a large limit unless you've confirmed that it works on gitlab.com scale.
scope :for_name_like, -> (query, limit: 5) do
- where(arel_table[:name].matches("#{sanitize_sql_like query}%")).limit(limit)
+ where('LOWER(environments.name) LIKE LOWER(?) || \'%\'', sanitize_sql_like(query)).limit(limit)
end
scope :for_project, -> (project) { where(project_id: project) }
scope :for_tier, -> (tier) { where(tier: tier).where.not(tier: nil) }
+ scope :for_type, -> (type) { where(environment_type: type) }
scope :unfoldered, -> { where(environment_type: nil) }
scope :with_rank, -> do
select('environments.*, rank() OVER (PARTITION BY project_id ORDER BY id DESC)')
@@ -431,9 +444,13 @@ class Environment < ApplicationRecord
return unless value
parser = ::Gitlab::Ci::Build::DurationParser.new(value)
- return if parser.seconds_from_now.nil?
+
+ return if parser.seconds_from_now.nil? && auto_stop_at.nil?
self.auto_stop_at = parser.seconds_from_now
+ rescue ChronicDuration::DurationParseError => ex
+ Gitlab::ErrorTracking.track_exception(ex, project_id: self.project_id, environment_id: self.id)
+ raise ex
end
def rollout_status
@@ -509,6 +526,12 @@ class Environment < ApplicationRecord
self.tier ||= guess_tier
end
+ def merge_request_not_changed
+ if merge_request_id_changed? && persisted?
+ errors.add(:merge_request, 'merge_request cannot be changed')
+ end
+ end
+
# Guessing the tier of the environment if it's not explicitly specified by users.
# See https://en.wikipedia.org/wiki/Deployment_environment for industry standard deployment environments
def guess_tier
diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb
index 43b2c7899a1..d06d0a99948 100644
--- a/app/models/environment_status.rb
+++ b/app/models/environment_status.rb
@@ -100,7 +100,7 @@ class EnvironmentStatus
def self.build_environments_status(mr, user, pipeline)
return [] unless pipeline
- pipeline.environments_in_self_and_descendants.includes(:project).available.map do |environment|
+ pipeline.environments_in_self_and_project_descendants.includes(:project).available.map do |environment|
next unless Ability.allowed?(user, :read_environment, environment)
EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha)
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 4953f24755c..12d73ef0d72 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -23,6 +23,7 @@ module ErrorTracking
self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] }
self.reactive_cache_work_type = :external_dependency
+ self.reactive_cache_hard_limit = ErrorTracking::SentryClient::RESPONSE_SIZE_LIMIT
self.table_name = 'project_error_tracking_settings'
@@ -103,9 +104,18 @@ module ErrorTracking
api_host
end
+ def sentry_response_limit_enabled?
+ Feature.enabled?(:error_tracking_sentry_limit, project)
+ end
+
+ def reactive_cache_limit_enabled?
+ sentry_response_limit_enabled?
+ end
+
def sentry_client
strong_memoize(:sentry_client) do
- ::ErrorTracking::SentryClient.new(api_url, token)
+ ::ErrorTracking::SentryClient
+ .new(api_url, token, validate_size_guarded_by_feature_flag: sentry_response_limit_enabled?)
end
end
@@ -127,14 +137,14 @@ module ErrorTracking
def issue_details(opts = {})
with_reactive_cache('issue_details', opts.stringify_keys) do |result|
- ensure_issue_belongs_to_project!(result[:issue].project_id)
+ ensure_issue_belongs_to_project!(result[:issue].project_id) if result[:issue]
result
end
end
def issue_latest_event(opts = {})
with_reactive_cache('issue_latest_event', opts.stringify_keys) do |result|
- ensure_issue_belongs_to_project!(result[:latest_event].project_id)
+ ensure_issue_belongs_to_project!(result[:latest_event].project_id) if result[:latest_event]
result
end
end
diff --git a/app/models/group.rb b/app/models/group.rb
index 55455d85531..1445e71b0bc 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -153,7 +153,7 @@ class Group < Namespace
after_create :post_create_hook
after_destroy :post_destroy_hook
- after_save :update_two_factor_requirement
+ after_commit :update_two_factor_requirement
after_update :path_changed_hook, if: :saved_change_to_path?
after_create -> { create_or_load_association(:group_feature) }
@@ -186,6 +186,27 @@ class Group < Namespace
where(project_creation_level: permitted_levels)
end
+ scope :shared_into_ancestors, -> (group) do
+ joins(:shared_group_links)
+ .where(group_group_links: { shared_group_id: group.self_and_ancestors })
+ end
+
+ # WARNING: This method should never be used on its own
+ # please do make sure the number of rows you are filtering is small
+ # enough for this query
+ #
+ # It's a replacement for `public_or_visible_to_user` that correctly
+ # supports subgroup permissions
+ scope :accessible_to_user, -> (user) do
+ if user
+ Preloaders::GroupPolicyPreloader.new(self, user).execute
+
+ select { |group| user.can?(:read_group, group) }
+ else
+ public_to_user
+ end
+ end
+
class << self
def sort_by_attribute(method)
if method == 'storage_size_desc'
@@ -614,11 +635,11 @@ class Group < Namespace
# 4. They belong to an ancestor group
def direct_and_indirect_users
User.from_union([
- User
- .where(id: direct_and_indirect_members.select(:user_id))
- .reorder(nil),
- project_users_with_descendants
- ])
+ User
+ .where(id: direct_and_indirect_members.select(:user_id))
+ .reorder(nil),
+ project_users_with_descendants
+ ])
end
# Returns all users (also inactive) that are members of the group because:
@@ -628,11 +649,11 @@ class Group < Namespace
# 4. They belong to an ancestor group
def direct_and_indirect_users_with_inactive
User.from_union([
- User
- .where(id: direct_and_indirect_members_with_inactive.select(:user_id))
- .reorder(nil),
- project_users_with_descendants
- ])
+ User
+ .where(id: direct_and_indirect_members_with_inactive.select(:user_id))
+ .reorder(nil),
+ project_users_with_descendants
+ ])
end
def users_count
@@ -672,14 +693,6 @@ class Group < Namespace
}
end
- def ci_variables_for(ref, project, environment: nil)
- cache_key = "ci_variables_for:group:#{self&.id}:project:#{project&.id}:ref:#{ref}:environment:#{environment}"
-
- ::Gitlab::SafeRequestStore.fetch(cache_key) do
- uncached_ci_variables_for(ref, project, environment: environment)
- end
- end
-
def member(user)
if group_members.loaded?
group_members.find { |gm| gm.user_id == user.id }
@@ -890,6 +903,18 @@ class Group < Namespace
end
end
+ def packages_policy_subject
+ if Feature.enabled?(:read_package_policy_rule, self)
+ ::Packages::Policies::Group.new(self)
+ else
+ self
+ end
+ end
+
+ def update_two_factor_requirement_for_members
+ direct_and_indirect_members.find_each(&:update_two_factor_requirement)
+ end
+
private
def feature_flag_enabled_for_self_or_ancestor?(feature_flag)
@@ -912,7 +937,7 @@ class Group < Namespace
def update_two_factor_requirement
return unless saved_change_to_require_two_factor_authentication? || saved_change_to_two_factor_grace_period?
- direct_and_indirect_members.find_each(&:update_two_factor_requirement)
+ Groups::UpdateTwoFactorRequirementForMembersWorker.perform_async(self.id)
end
def path_changed_hook
@@ -1031,26 +1056,6 @@ class Group < Namespace
def enable_shared_runners!
update!(shared_runners_enabled: true)
end
-
- def uncached_ci_variables_for(ref, project, environment: nil)
- list_of_ids = if root_ancestor.use_traversal_ids?
- [self] + ancestors(hierarchy_order: :asc)
- else
- [self] + ancestors
- end
-
- variables = Ci::GroupVariable.where(group: list_of_ids)
- variables = variables.unprotected unless project.protected_for?(ref)
-
- variables = if environment
- variables.on_environment(environment)
- else
- variables.where(environment_scope: '*')
- end
-
- variables = variables.group_by(&:group_id)
- list_of_ids.reverse.flat_map { |group| variables[group.id] }.compact
- end
end
Group.prepend_mod_with('Group')
diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb
index 8dd245a6ab5..7005c8593bd 100644
--- a/app/models/group_group_link.rb
+++ b/app/models/group_group_link.rb
@@ -19,6 +19,10 @@ class GroupGroupLink < ApplicationRecord
where(group_access: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER])
end
+ scope :with_owner_access, -> do
+ where(group_access: [Gitlab::Access::OWNER])
+ end
+
scope :groups_accessible_via, -> (shared_with_group_ids) do
links = where(shared_with_group_id: shared_with_group_ids)
# a group share also gives you access to the descendants of the group being shared,
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
index 24e5f193a32..3fc3f193f19 100644
--- a/app/models/hooks/web_hook_log.rb
+++ b/app/models/hooks/web_hook_log.rb
@@ -25,7 +25,7 @@ class WebHookLog < ApplicationRecord
before_save :redact_author_email
def self.recent
- where('created_at >= ?', 2.days.ago.beginning_of_day)
+ where(created_at: 2.days.ago.beginning_of_day..Time.zone.now)
.order(created_at: :desc)
end
diff --git a/app/models/incident_management/timeline_event.rb b/app/models/incident_management/timeline_event.rb
index d30d6906e14..dd0d3c6585d 100644
--- a/app/models/incident_management/timeline_event.rb
+++ b/app/models/incident_management/timeline_event.rb
@@ -20,6 +20,6 @@ module IncidentManagement
validates :action, presence: true, length: { maximum: 128 }
validates :note, :note_html, presence: true, length: { maximum: 10_000 }
- scope :order_occurred_at_asc, -> { reorder(occurred_at: :asc) }
+ scope :order_occurred_at_asc_id_asc, -> { reorder(occurred_at: :asc, id: :asc) }
end
end
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 6d755016380..aecf9529a14 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -401,9 +401,9 @@ class Integration < ApplicationRecord
.or(where(type: integration.type, instance: true)).select(:id)
from_union([
- where(type: integration.type, inherit_from_id: inherit_from_ids, group: integration.group.descendants),
- where(type: integration.type, inherit_from_id: inherit_from_ids, project: Project.in_namespace(integration.group.self_and_descendants))
- ])
+ where(type: integration.type, inherit_from_id: inherit_from_ids, group: integration.group.descendants),
+ where(type: integration.type, inherit_from_id: inherit_from_ids, project: Project.in_namespace(integration.group.self_and_descendants))
+ ])
end
def activated?
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
index bb0fb6b9079..4479725a33b 100644
--- a/app/models/integrations/datadog.rb
+++ b/app/models/integrations/datadog.rb
@@ -10,7 +10,7 @@ module Integrations
URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_DOMAIN}/account_management/api-app-keys/"
SUPPORTED_EVENTS = %w[
- pipeline job
+ pipeline job archive_trace
].freeze
TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze
@@ -38,14 +38,6 @@ module Integrations
SUPPORTED_EVENTS
end
- def supported_events
- events = super
-
- return events + ['archive_trace'] if Feature.enabled?(:datadog_integration_logs_collection, parent)
-
- events
- end
-
def self.default_test_event
'pipeline'
end
@@ -77,7 +69,7 @@ module Integrations
end
def fields
- f = [
+ [
{
type: 'text',
name: 'datadog_site',
@@ -110,21 +102,15 @@ module Integrations
linkClose: '</a>'.html_safe
},
required: true
- }
- ]
-
- if Feature.enabled?(:datadog_integration_logs_collection, parent)
- f.append({
+ },
+ {
type: 'checkbox',
name: 'archive_trace_events',
title: s_('Logs'),
checkbox_label: s_('Enable logs collection'),
help: s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.'),
required: false
- })
- end
-
- f += [
+ },
{
type: 'text',
name: 'datadog_service',
@@ -161,8 +147,6 @@ module Integrations
}
}
]
-
- f
end
override :hook_url
diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb
index ec8a12e4760..d0389b82410 100644
--- a/app/models/integrations/discord.rb
+++ b/app/models/integrations/discord.rb
@@ -6,6 +6,24 @@ module Integrations
class Discord < BaseChatNotification
ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze
+ undef :notify_only_broken_pipelines
+
+ field :webhook,
+ section: SECTION_TYPE_CONNECTION,
+ placeholder: 'https://discordapp.com/api/webhooks/…',
+ help: 'URL to the webhook for the Discord channel.',
+ required: true
+
+ field :notify_only_broken_pipelines,
+ type: 'checkbox',
+ section: SECTION_TYPE_CONFIGURATION
+
+ field :branches_to_be_notified,
+ type: 'select',
+ section: SECTION_TYPE_CONFIGURATION,
+ title: -> { s_('Integrations|Branches for which notifications are to be sent') },
+ choices: -> { branch_choices }
+
def title
s_("DiscordService|Discord Notifications")
end
@@ -18,6 +36,10 @@ module Integrations
"discord"
end
+ def fields
+ self.class.fields + build_event_channels
+ end
+
def help
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 }
@@ -31,30 +53,6 @@ module Integrations
%w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page]
end
- def default_fields
- [
- {
- type: 'text',
- section: SECTION_TYPE_CONNECTION,
- name: 'webhook',
- placeholder: 'https://discordapp.com/api/webhooks/…',
- help: 'URL to the webhook for the Discord channel.'
- },
- {
- type: 'checkbox',
- section: SECTION_TYPE_CONFIGURATION,
- name: 'notify_only_broken_pipelines'
- },
- {
- type: 'select',
- section: SECTION_TYPE_CONFIGURATION,
- name: 'branches_to_be_notified',
- title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: self.class.branch_choices
- }
- ]
- end
-
def sections
[
{
diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb
index df112ad6ca8..6e7f31aa030 100644
--- a/app/models/integrations/hangouts_chat.rb
+++ b/app/models/integrations/hangouts_chat.rb
@@ -47,8 +47,31 @@ module Integrations
private
def notify(message, opts)
+ url = webhook.dup
+
+ key = parse_thread_key(message)
+ url = Gitlab::Utils.add_url_parameters(url, { threadKey: key }) if key
+
simple_text = parse_simple_text_message(message)
- ::HangoutsChat::Sender.new(webhook).simple(simple_text)
+ ::HangoutsChat::Sender.new(url).simple(simple_text)
+ end
+
+ # Returns an appropriate key for threading messages in google chat
+ def parse_thread_key(message)
+ case message
+ when Integrations::ChatMessage::NoteMessage
+ message.target
+ when Integrations::ChatMessage::IssueMessage
+ "issue #{Issue.reference_prefix}#{message.issue_iid}"
+ when Integrations::ChatMessage::MergeMessage
+ "merge request #{MergeRequest.reference_prefix}#{message.merge_request_iid}"
+ when Integrations::ChatMessage::PushMessage
+ "push #{message.project_name}_#{message.ref}"
+ when Integrations::ChatMessage::PipelineMessage
+ "pipeline #{message.pipeline_id}"
+ when Integrations::ChatMessage::WikiPageMessage
+ "wiki_page #{message.wiki_page_url}"
+ end
end
def parse_simple_text_message(message)
diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb
index 03913a71d47..58eabcfd378 100644
--- a/app/models/integrations/harbor.rb
+++ b/app/models/integrations/harbor.rb
@@ -24,6 +24,10 @@ module Integrations
s_("HarborIntegration|After the Harbor integration is activated, global variables '$HARBOR_USERNAME', '$HARBOR_HOST', '$HARBOR_OCI', '$HARBOR_PASSWORD', '$HARBOR_URL' and '$HARBOR_PROJECT' will be created for CI/CD use.")
end
+ def hostname
+ Gitlab::Utils.parse_url(url).hostname
+ end
+
class << self
def to_param
name.demodulize.downcase
diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb
index 8bc296e0320..f5b6595fff2 100644
--- a/app/models/integrations/shimo.rb
+++ b/app/models/integrations/shimo.rb
@@ -9,8 +9,6 @@ module Integrations
required: true
def render?
- return false unless Feature.enabled?(:shimo_integration, project)
-
valid? && activated?
end
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index b502d5e354d..d141061062a 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -143,10 +143,7 @@ class InternalId < ApplicationRecord
def track_greatest(new_value)
InternalId.internal_id_transactions_increment(operation: :track_greatest, usage: usage)
- function = Arel::Nodes::NamedFunction.new('GREATEST', [
- arel_table[:last_value],
- new_value.to_i
- ])
+ function = Arel::Nodes::NamedFunction.new('GREATEST', [arel_table[:last_value], new_value.to_i])
next_iid = update_record!(subject, scope, usage, function)
return next_iid if next_iid
diff --git a/app/models/issue.rb b/app/models/issue.rb
index df8ee34b3c3..153747c75df 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -254,31 +254,6 @@ class Issue < ApplicationRecord
alias_method :with_state, :with_state_id
alias_method :with_states, :with_state_ids
- def build_keyset_order_on_joined_column(scope:, attribute_name:, column:, direction:, nullable:)
- reversed_direction = direction == :asc ? :desc : :asc
-
- # rubocop: disable GitlabSecurity/PublicSend
- order = ::Gitlab::Pagination::Keyset::Order.build([
- ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: attribute_name,
- column_expression: column,
- order_expression: column.send(direction).send(nullable),
- reversed_order_expression: column.send(reversed_direction).send(nullable),
- order_direction: direction,
- distinct: false,
- add_to_projections: true,
- nullable: nullable
- ),
- ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'id',
- order_expression: arel_table['id'].desc
- )
- ])
- # rubocop: enable GitlabSecurity/PublicSend
-
- order.apply_cursor_conditions(scope).order(order)
- end
-
override :order_upvotes_desc
def order_upvotes_desc
reorder(upvotes_count: :desc)
@@ -293,16 +268,6 @@ class Issue < ApplicationRecord
def pg_full_text_search(search_term)
super.where('issue_search_data.project_id = issues.project_id')
end
-
- override :full_search
- def full_search(query, matched_columns: nil, use_minimum_char_limit: true)
- return super if query.match?(IssuableFinder::FULL_TEXT_SEARCH_TERM_REGEX)
-
- super.where(
- 'issues.title NOT SIMILAR TO :pattern OR issues.description NOT SIMILAR TO :pattern',
- pattern: IssuableFinder::FULL_TEXT_SEARCH_TERM_PATTERN
- )
- end
end
def next_object_by_relative_position(ignoring: nil, order: :asc)
@@ -406,8 +371,6 @@ class Issue < ApplicationRecord
attribute_name: 'relative_position',
column_expression: arel_table[:relative_position],
order_expression: Issue.arel_table[:relative_position].asc.nulls_last,
- reversed_order_expression: Issue.arel_table[:relative_position].desc.nulls_last,
- order_direction: :asc,
nullable: :nulls_last,
distinct: false
)
@@ -695,11 +658,11 @@ class Issue < ApplicationRecord
return unless persisted?
if confidential? && WorkItems::ParentLink.has_public_children?(id)
- errors.add(:confidential, _('confidential parent can not be used if there are non-confidential children.'))
+ errors.add(:base, _('A confidential issue cannot have a parent that already has non-confidential children.'))
end
if !confidential? && WorkItems::ParentLink.has_confidential_parent?(id)
- errors.add(:confidential, _('associated parent is confidential and can not have non-confidential children.'))
+ errors.add(:base, _('A non-confidential issue cannot have a confidential parent.'))
end
end
@@ -722,7 +685,7 @@ class Issue < ApplicationRecord
end
def record_create_action
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author, project: project)
end
# Returns `true` if this Issue is visible to everybody.
diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb
index 8befe9a9230..0a2d3ba0749 100644
--- a/app/models/jira_connect_installation.rb
+++ b/app/models/jira_connect_installation.rb
@@ -24,4 +24,10 @@ class JiraConnectInstallation < ApplicationRecord
def client
Atlassian::JiraConnect::Client.new(base_url, shared_secret)
end
+
+ def oauth_authorization_url
+ return Gitlab.config.gitlab.url if instance_url.blank? || Feature.disabled?(:jira_connect_oauth_self_managed)
+
+ instance_url
+ end
end
diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb
index 94444f4b6d3..f28e8f81b40 100644
--- a/app/models/loose_foreign_keys/deleted_record.rb
+++ b/app/models/loose_foreign_keys/deleted_record.rb
@@ -12,7 +12,7 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel
next_partition_if: -> (active_partition) do
oldest_record_in_partition = LooseForeignKeys::DeletedRecord
.select(:id, :created_at)
- .for_partition(active_partition)
+ .for_partition(active_partition.value)
.order(:id)
.limit(1)
.take
@@ -22,7 +22,7 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel
end,
detach_partition_if: -> (partition) do
!LooseForeignKeys::DeletedRecord
- .for_partition(partition)
+ .for_partition(partition.value)
.status_pending
.exists?
end
diff --git a/app/models/member.rb b/app/models/member.rb
index 0cd1e022617..c5351d5447b 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -60,6 +60,7 @@ class Member < ApplicationRecord
if: :project_bot?
validate :access_level_inclusion
validate :validate_member_role_access_level
+ validate :validate_access_level_locked_for_member_role, on: :update
scope :with_invited_user_state, -> do
joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email')
@@ -73,10 +74,7 @@ class Member < ApplicationRecord
projects = source.root_ancestor.all_projects
project_members = Member.default_scoped.where(source: projects).select(*Member.cached_column_list)
- Member.default_scoped.from_union([
- group_members,
- project_members
- ]).merge(self)
+ Member.default_scoped.from_union([group_members, project_members]).merge(self)
end
scope :excluding_users, ->(user_ids) do
@@ -186,14 +184,85 @@ class Member < ApplicationRecord
unscoped.from(distinct_members, :members)
end
- scope :order_name_asc, -> { left_join_users.reorder(User.arel_table[:name].asc.nulls_last) }
- scope :order_name_desc, -> { left_join_users.reorder(User.arel_table[:name].desc.nulls_last) }
- scope :order_recent_sign_in, -> { left_join_users.reorder(User.arel_table[:last_sign_in_at].desc.nulls_last) }
- scope :order_oldest_sign_in, -> { left_join_users.reorder(User.arel_table[:last_sign_in_at].asc.nulls_last) }
- scope :order_recent_last_activity, -> { left_join_users.reorder(User.arel_table[:last_activity_on].desc.nulls_last) }
- scope :order_oldest_last_activity, -> { left_join_users.reorder(User.arel_table[:last_activity_on].asc.nulls_first) }
- scope :order_recent_created_user, -> { left_join_users.reorder(User.arel_table[:created_at].desc.nulls_last) }
- scope :order_oldest_created_user, -> { left_join_users.reorder(User.arel_table[:created_at].asc.nulls_first) }
+ scope :order_name_asc, -> do
+ build_keyset_order_on_joined_column(
+ scope: left_join_users,
+ attribute_name: 'member_user_full_name',
+ column: User.arel_table[:name],
+ direction: :asc,
+ nullable: :nulls_last
+ )
+ end
+
+ scope :order_name_desc, -> do
+ build_keyset_order_on_joined_column(
+ scope: left_join_users,
+ attribute_name: 'member_user_full_name',
+ column: User.arel_table[:name],
+ direction: :desc,
+ nullable: :nulls_last
+ )
+ end
+
+ scope :order_oldest_sign_in, -> do
+ build_keyset_order_on_joined_column(
+ scope: left_join_users,
+ attribute_name: 'member_user_last_sign_in_at',
+ column: User.arel_table[:last_sign_in_at],
+ direction: :asc,
+ nullable: :nulls_last
+ )
+ end
+
+ scope :order_recent_sign_in, -> do
+ build_keyset_order_on_joined_column(
+ scope: left_join_users,
+ attribute_name: 'member_user_last_sign_in_at',
+ column: User.arel_table[:last_sign_in_at],
+ direction: :desc,
+ nullable: :nulls_last
+ )
+ end
+
+ scope :order_oldest_last_activity, -> do
+ build_keyset_order_on_joined_column(
+ scope: left_join_users,
+ attribute_name: 'member_user_last_activity_on',
+ column: User.arel_table[:last_activity_on],
+ direction: :asc,
+ nullable: :nulls_first
+ )
+ end
+
+ scope :order_recent_last_activity, -> do
+ build_keyset_order_on_joined_column(
+ scope: left_join_users,
+ attribute_name: 'member_user_last_activity_on',
+ column: User.arel_table[:last_activity_on],
+ direction: :desc,
+ nullable: :nulls_last
+ )
+ end
+
+ scope :order_oldest_created_user, -> do
+ build_keyset_order_on_joined_column(
+ scope: left_join_users,
+ attribute_name: 'member_user_created_at',
+ column: User.arel_table[:created_at],
+ direction: :asc,
+ nullable: :nulls_first
+ )
+ end
+
+ scope :order_recent_created_user, -> do
+ build_keyset_order_on_joined_column(
+ scope: left_join_users,
+ attribute_name: 'member_user_created_at',
+ column: User.arel_table[:created_at],
+ direction: :desc,
+ nullable: :nulls_last
+ )
+ end
scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) }
@@ -438,6 +507,14 @@ class Member < ApplicationRecord
end
end
+ def validate_access_level_locked_for_member_role
+ return unless member_role_id
+
+ if access_level_changed?
+ errors.add(:access_level, _("cannot be changed since member is associated with a custom role"))
+ end
+ end
+
def send_invite
# override in subclass
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 3c06e1aa983..a57cb97e936 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -20,7 +20,7 @@ class MergeRequest < ApplicationRecord
include IgnorableColumns
include MilestoneEventable
include StateEventable
- include ApprovableBase
+ include Approvable
include IdInOrdered
include Todoable
@@ -67,6 +67,8 @@ class MergeRequest < ApplicationRecord
has_one :merge_head_diff,
-> { merge_head }, inverse_of: :merge_request, class_name: 'MergeRequestDiff'
has_one :cleanup_schedule, inverse_of: :merge_request
+ has_one :predictions, inverse_of: :merge_request
+ delegate :suggested_reviewers, to: :predictions
belongs_to :latest_merge_request_diff, class_name: 'MergeRequestDiff'
manual_inverse_association :latest_merge_request_diff, :merge_request
@@ -116,6 +118,7 @@ class MergeRequest < ApplicationRecord
has_many :draft_notes
has_many :reviews, inverse_of: :merge_request
+ has_many :created_environments, class_name: 'Environment', foreign_key: :merge_request_id, inverse_of: :merge_request
KNOWN_MERGE_PARAMS = [
:auto_merge_strategy,
@@ -343,23 +346,24 @@ class MergeRequest < ApplicationRecord
column_expression = MergeRequest::Metrics.arel_table[metric]
column_expression_with_direction = direction == 'ASC' ? column_expression.asc : column_expression.desc
- order = Gitlab::Pagination::Keyset::Order.build([
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: "merge_request_metrics_#{metric}",
- column_expression: column_expression,
- order_expression: column_expression_with_direction.nulls_last,
- reversed_order_expression: column_expression_with_direction.reverse.nulls_first,
- order_direction: direction,
- nullable: :nulls_last,
- distinct: false,
- add_to_projections: true
- ),
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'merge_request_metrics_id',
- order_expression: MergeRequest::Metrics.arel_table[:id].desc,
- add_to_projections: true
- )
- ])
+ order = Gitlab::Pagination::Keyset::Order.build(
+ [
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: "merge_request_metrics_#{metric}",
+ column_expression: column_expression,
+ order_expression: column_expression_with_direction.nulls_last,
+ reversed_order_expression: column_expression_with_direction.reverse.nulls_first,
+ order_direction: direction,
+ nullable: :nulls_last,
+ distinct: false,
+ add_to_projections: true
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'merge_request_metrics_id',
+ order_expression: MergeRequest::Metrics.arel_table[:id].desc,
+ add_to_projections: true
+ )
+ ])
order.apply_cursor_conditions(join_metrics).order(order)
end
@@ -417,17 +421,6 @@ class MergeRequest < ApplicationRecord
)
end
- scope :attention, ->(user) do
- # rubocop: disable Gitlab/Union
- union = Gitlab::SQL::Union.new([
- MergeRequestReviewer.select(:merge_request_id).where(user_id: user.id, state: MergeRequestReviewer.states[:attention_requested]),
- MergeRequestAssignee.select(:merge_request_id).where(user_id: user.id, state: MergeRequestAssignee.states[:attention_requested])
- ])
- # rubocop: enable Gitlab/Union
-
- with(Gitlab::SQL::CTE.new(:reviewers_and_assignees, union).to_arel).where('merge_requests.id in (select merge_request_id from reviewers_and_assignees)')
- end
-
def self.total_time_to_merge
join_metrics
.merge(MergeRequest::Metrics.with_valid_time_to_merge)
@@ -1187,41 +1180,13 @@ class MergeRequest < ApplicationRecord
]
end
- def detailed_merge_status
- if cannot_be_merged_rechecking? || preparing? || checking?
- return :checking
- elsif unchecked?
- return :unchecked
- end
-
- checks = execute_merge_checks
-
- if checks.success?
- :mergeable
- else
- checks.failure_reason
- end
- end
-
- # rubocop: disable CodeReuse/ServiceClass
def mergeable_state?(skip_ci_check: false, skip_discussions_check: false)
- if Feature.enabled?(:improved_mergeability_checks, self.project)
- additional_checks = execute_merge_checks(params: {
- skip_ci_check: skip_ci_check,
- skip_discussions_check: skip_discussions_check
- })
- additional_checks.execute.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
- end
+ additional_checks = execute_merge_checks(params: {
+ skip_ci_check: skip_ci_check,
+ skip_discussions_check: skip_discussions_check
+ })
+ additional_checks.success?
end
- # rubocop: enable CodeReuse/ServiceClass
def ff_merge_possible?
project.repository.ancestor?(target_branch_sha, diff_head_sha)
@@ -1318,7 +1283,6 @@ class MergeRequest < ApplicationRecord
# running `ReferenceExtractor` on each of them separately.
# This optimization does not apply to issues from external sources.
def cache_merge_request_closes_issues!(current_user = self.author)
- return unless project.issues_enabled?
return if closed? || merged?
transaction do
@@ -1489,7 +1453,7 @@ class MergeRequest < ApplicationRecord
end
def environments_in_head_pipeline(deployment_status: nil)
- actual_head_pipeline&.environments_in_self_and_descendants(deployment_status: deployment_status) || Environment.none
+ actual_head_pipeline&.environments_in_self_and_project_descendants(deployment_status: deployment_status) || Environment.none
end
def fetch_ref!
@@ -1589,7 +1553,7 @@ class MergeRequest < ApplicationRecord
end
def has_test_reports?
- actual_head_pipeline&.has_reports?(Ci::JobArtifact.test_reports)
+ actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test))
end
def predefined_variables
@@ -1619,7 +1583,7 @@ class MergeRequest < ApplicationRecord
end
def has_accessibility_reports?
- actual_head_pipeline.present? && actual_head_pipeline.has_reports?(Ci::JobArtifact.accessibility_reports)
+ actual_head_pipeline.present? && actual_head_pipeline.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:accessibility))
end
def has_coverage_reports?
@@ -1627,7 +1591,7 @@ class MergeRequest < ApplicationRecord
end
def has_terraform_reports?
- actual_head_pipeline&.has_reports?(Ci::JobArtifact.terraform_reports)
+ actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:terraform))
end
def compare_accessibility_reports
@@ -1667,7 +1631,7 @@ class MergeRequest < ApplicationRecord
end
def has_codequality_reports?
- actual_head_pipeline&.has_reports?(Ci::JobArtifact.codequality_reports)
+ actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:codequality))
end
def compare_codequality_reports
@@ -1717,11 +1681,11 @@ class MergeRequest < ApplicationRecord
end
def has_sast_reports?
- !!actual_head_pipeline&.has_reports?(::Ci::JobArtifact.sast_reports)
+ !!actual_head_pipeline&.complete_and_has_reports?(::Ci::JobArtifact.of_report_type(:sast))
end
def has_secret_detection_reports?
- !!actual_head_pipeline&.has_reports?(::Ci::JobArtifact.secret_detection_reports)
+ !!actual_head_pipeline&.complete_and_has_reports?(::Ci::JobArtifact.of_report_type(:secret_detection))
end
def compare_sast_reports(current_user)
@@ -2019,6 +1983,12 @@ class MergeRequest < ApplicationRecord
false # Overridden in EE
end
+ def execute_merge_checks(params: {})
+ # rubocop: disable CodeReuse/ServiceClass
+ MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: params).execute
+ # rubocop: enable CodeReuse/ServiceClass
+ end
+
private
attr_accessor :skip_fetch_ref
@@ -2072,12 +2042,6 @@ class MergeRequest < ApplicationRecord
def report_type_enabled?(report_type)
!!actual_head_pipeline&.batch_lookup_report_artifact_for_file_type(report_type)
end
-
- def execute_merge_checks(params: {})
- # rubocop: disable CodeReuse/ServiceClass
- MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: params).execute
- # rubocop: enable CodeReuse/ServiceClass
- end
end
MergeRequest.prepend_mod_with('MergeRequest')
diff --git a/app/models/merge_request/predictions.rb b/app/models/merge_request/predictions.rb
new file mode 100644
index 00000000000..ef9e00b5f74
--- /dev/null
+++ b/app/models/merge_request/predictions.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class MergeRequest::Predictions < ApplicationRecord # rubocop:disable Style/ClassAndModuleChildren
+ belongs_to :merge_request, inverse_of: :predictions
+
+ validates :suggested_reviewers, json_schema: { filename: 'merge_request_predictions_suggested_reviewers' }
+end
diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb
index fd8e5860040..be3a1d42eac 100644
--- a/app/models/merge_request_assignee.rb
+++ b/app/models/merge_request_assignee.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
class MergeRequestAssignee < ApplicationRecord
- include MergeRequestReviewerState
+ include IgnorableColumns
+ ignore_column %i[state updated_state_by_user_id], remove_with: '15.6', remove_after: '2022-10-22'
belongs_to :merge_request, touch: true
belongs_to :assignee, class_name: "User", foreign_key: :user_id, inverse_of: :merge_request_assignees
@@ -11,6 +12,6 @@ class MergeRequestAssignee < ApplicationRecord
scope :in_projects, ->(project_ids) { joins(:merge_request).where(merge_requests: { target_project_id: project_ids }) }
def cache_key
- [model_name.cache_key, id, state, assignee.cache_key]
+ [model_name.cache_key, id, assignee.cache_key]
end
end
diff --git a/app/models/merge_request_reviewer.rb b/app/models/merge_request_reviewer.rb
index 4abf0fa09f0..4b5b71481d3 100644
--- a/app/models/merge_request_reviewer.rb
+++ b/app/models/merge_request_reviewer.rb
@@ -2,6 +2,8 @@
class MergeRequestReviewer < ApplicationRecord
include MergeRequestReviewerState
+ include IgnorableColumns
+ ignore_column :updated_state_by_user_id, remove_with: '15.6', remove_after: '2022-10-22'
belongs_to :merge_request
belongs_to :reviewer, class_name: 'User', foreign_key: :user_id, inverse_of: :merge_request_reviewers
diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb
index e181217f01c..29e1ba88528 100644
--- a/app/models/ml/candidate.rb
+++ b/app/models/ml/candidate.rb
@@ -2,11 +2,24 @@
module Ml
class Candidate < ApplicationRecord
+ enum status: { running: 0, scheduled: 1, finished: 2, failed: 3, killed: 4 }
+
validates :iid, :experiment, presence: true
+ validates :status, inclusion: { in: statuses.keys }
belongs_to :experiment, class_name: 'Ml::Experiment'
belongs_to :user
has_many :metrics, class_name: 'Ml::CandidateMetric'
has_many :params, class_name: 'Ml::CandidateParam'
+
+ default_value_for(:iid) { SecureRandom.uuid }
+
+ class << self
+ def with_project_id_and_iid(project_id, iid)
+ return unless project_id.present? && iid.present?
+
+ joins(:experiment).find_by(experiment: { project_id: project_id }, iid: iid)
+ end
+ end
end
end
diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb
index 7ef9c70ba7e..e4e9baac4c8 100644
--- a/app/models/ml/experiment.rb
+++ b/app/models/ml/experiment.rb
@@ -2,11 +2,33 @@
module Ml
class Experiment < ApplicationRecord
- validates :name, :iid, :project, presence: true
- validates :iid, :name, uniqueness: { scope: :project, message: "should be unique in the project" }
+ include AtomicInternalId
+
+ validates :name, :project, presence: true
+ validates :name, uniqueness: { scope: :project, message: "should be unique in the project" }
belongs_to :project
belongs_to :user
has_many :candidates, class_name: 'Ml::Candidate'
+
+ has_internal_id :iid, scope: :project
+
+ def artifact_location
+ 'not_implemented'
+ end
+
+ class << self
+ def by_project_id_and_iid(project_id, iid)
+ find_by(project_id: project_id, iid: iid)
+ end
+
+ def by_project_id_and_name(project_id, name)
+ find_by(project_id: project_id, name: name)
+ end
+
+ def has_record?(project_id, name)
+ where(project_id: project_id, name: name).exists?
+ end
+ end
end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 06f49f16d66..0ffd5c446d3 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -43,6 +43,8 @@ class Namespace < ApplicationRecord
# The first date in https://docs.gitlab.com/ee/user/usage_quotas.html#namespace-storage-limit-enforcement-schedule
# Determines when we start enforcing namespace storage
MIN_STORAGE_ENFORCEMENT_DATE = Date.new(2022, 10, 19)
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/367531
+ MIN_STORAGE_ENFORCEMENT_USAGE = 5.gigabytes
cache_markdown_field :description, pipeline: :description
@@ -59,7 +61,7 @@ class Namespace < ApplicationRecord
has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace'
has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner'
has_many :pending_builds, class_name: 'Ci::PendingBuild'
- has_one :onboarding_progress
+ has_one :onboarding_progress, class_name: 'Onboarding::Progress'
# This should _not_ be `inverse_of: :namespace`, because that would also set
# `user.namespace` when this user creates a group with themselves as `owner`.
@@ -126,8 +128,9 @@ class Namespace < ApplicationRecord
delegate :avatar_url, to: :owner, allow_nil: true
delegate :prevent_sharing_groups_outside_hierarchy, :prevent_sharing_groups_outside_hierarchy=,
to: :namespace_settings, allow_nil: true
+ delegate :show_diff_preview_in_email, :show_diff_preview_in_email?, :show_diff_preview_in_email=,
+ to: :namespace_settings
- after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_parent_id? }
after_save :reload_namespace_details
after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') }
@@ -136,6 +139,7 @@ class Namespace < ApplicationRecord
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? }
+ after_sync_traversal_ids :schedule_sync_event_worker # custom callback defined in Namespaces::Traversal::Linear
# Legacy Storage specific hooks
@@ -172,13 +176,17 @@ class Namespace < ApplicationRecord
end
scope :sorted_by_similarity_and_parent_id_desc, -> (search) do
- order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [
- { column: arel_table["path"], multiplier: 1 },
- { column: arel_table["name"], multiplier: 0.7 }
- ])
+ order_expression = Gitlab::Database::SimilarityScore.build_expression(
+ search: search,
+ rules: [
+ { column: arel_table["path"], multiplier: 1 },
+ { column: arel_table["name"], multiplier: 0.7 }
+ ])
reorder(order_expression.desc, Namespace.arel_table['parent_id'].desc.nulls_last, Namespace.arel_table['id'].desc)
end
+ scope :with_shared_runners_enabled, -> { where(shared_runners_enabled: true) }
+
# Make sure that the name is same as strong_memoize name in root_ancestor
# method
attr_writer :root_ancestor, :emails_disabled_memoized
@@ -362,7 +370,7 @@ class Namespace < ApplicationRecord
end
def any_project_with_shared_runners_enabled?
- projects.with_shared_runners.any?
+ projects.with_shared_runners_enabled.any?
end
def user_ids_for_project_authorizations
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 595e34821af..6a87fba57ac 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -4,6 +4,11 @@ class NamespaceSetting < ApplicationRecord
include CascadingNamespaceSettingAttribute
include Sanitizable
include ChronicDurationAttribute
+ include IgnorableColumns
+
+ ignore_columns %i[exclude_from_free_user_cap include_for_free_user_cap_preview],
+ remove_with: '15.5',
+ remove_after: '2022-09-23'
cascading_attr :delayed_project_removal
@@ -53,8 +58,18 @@ class NamespaceSetting < ApplicationRecord
namespace.root_ancestor.prevent_sharing_groups_outside_hierarchy
end
+ def show_diff_preview_in_email?
+ return show_diff_preview_in_email unless namespace.has_parent?
+
+ all_ancestors_allow_diff_preview_in_email?
+ end
+
private
+ def all_ancestors_allow_diff_preview_in_email?
+ !self.class.where(namespace_id: namespace.self_and_ancestors, show_diff_preview_in_email: false).exists?
+ end
+
def normalize_default_branch_name
self.default_branch_name = default_branch_name.presence
end
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index 687fa6a5334..16a9c20dfdc 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -47,6 +47,8 @@ module Namespaces
# 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]
+
+ define_model_callbacks :sync_traversal_ids
end
class_methods do
@@ -208,10 +210,12 @@ module Namespaces
#
# NOTE: self.traversal_ids will be stale. Reload for a fresh record.
def sync_traversal_ids
- # Clear any previously memoized root_ancestor as our ancestors have changed.
- clear_memoization(:root_ancestor)
+ run_callbacks :sync_traversal_ids do
+ # Clear any previously memoized root_ancestor as our ancestors have changed.
+ clear_memoization(:root_ancestor)
- Namespace::TraversalHierarchy.for_namespace(self).sync_traversal_ids!
+ Namespace::TraversalHierarchy.for_namespace(self).sync_traversal_ids!
+ end
end
# Lock the root of the hierarchy we just left, and lock the root of the hierarchy
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index 81ac026d7ff..843de9bce33 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -41,24 +41,13 @@ module Namespaces
def self_and_descendants(include_self: true)
return super unless use_traversal_ids_for_descendants_scopes?
- if Feature.enabled?(:traversal_ids_btree)
- self_and_descendants_with_comparison_operators(include_self: include_self)
- else
- records = self_and_descendants_with_duplicates_with_array_operator(include_self: include_self)
- distinct = records.select('DISTINCT on(namespaces.id) namespaces.*')
- distinct.normal_select
- end
+ self_and_descendants_with_comparison_operators(include_self: include_self)
end
def self_and_descendant_ids(include_self: true)
return super unless use_traversal_ids_for_descendants_scopes?
- if Feature.enabled?(:traversal_ids_btree)
- self_and_descendants_with_comparison_operators(include_self: include_self).as_ids
- else
- self_and_descendants_with_duplicates_with_array_operator(include_self: include_self)
- .select('DISTINCT namespaces.id')
- end
+ self_and_descendants(include_self: include_self).as_ids
end
def self_and_hierarchy
@@ -181,20 +170,6 @@ module Namespaces
Arel::Nodes::NamedFunction.new('unnest', args)
end
- def self_and_descendants_with_duplicates_with_array_operator(include_self: true)
- base_ids = select(:id)
-
- records = unscoped
- .from("namespaces, (#{base_ids.to_sql}) base")
- .where('namespaces.traversal_ids @> ARRAY[base.id]')
-
- if include_self
- records
- else
- records.where('namespaces.id <> base.id')
- end
- end
-
def superset_cte(base_name)
superset_sql = <<~SQL
SELECT d1.traversal_ids
diff --git a/app/models/note.rb b/app/models/note.rb
index 1715f7cdc3b..daac489757b 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -164,6 +164,9 @@ class Note < ApplicationRecord
scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) }
before_validation :nullify_blank_type, :nullify_blank_line_code
+ # Syncs `confidential` with `internal` as we rename the column.
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/367923
+ before_create :set_internal_flag
after_save :keep_around_commit, if: :for_project_noteable?, unless: -> { importing? || skip_keep_around_commits }
after_save :expire_etag_cache, unless: :importing?
after_save :touch_noteable, unless: :importing?
@@ -813,6 +816,10 @@ class Note < ApplicationRecord
def noteable_can_have_confidential_note?
for_issue?
end
+
+ def set_internal_flag
+ self.internal = confidential if confidential
+ end
end
Note.prepend_mod_with('Note')
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
index b3eaed154e2..caa24377791 100644
--- a/app/models/notification_recipient.rb
+++ b/app/models/notification_recipient.rb
@@ -38,6 +38,7 @@ class NotificationRecipient
return !unsubscribed? if @type == :subscription
return false unless suitable_notification_level?
+ return false if email_blocked?
# check this last because it's expensive
# nobody should receive notifications if they've specifically unsubscribed
@@ -95,6 +96,15 @@ class NotificationRecipient
end
end
+ def email_blocked?
+ return false if Feature.disabled?(:block_emails_with_failures)
+
+ recipient_email = user.notification_email_for(@group)
+
+ Gitlab::ApplicationRateLimiter.peek(:permanent_email_failure, scope: recipient_email) ||
+ Gitlab::ApplicationRateLimiter.peek(:temporary_email_failure, scope: recipient_email)
+ end
+
def has_access?
DeclarativePolicy.subject_scope do
break false unless user.can?(:receive_notifications)
diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb
index 7d71e15d3c5..eac99e8d441 100644
--- a/app/models/oauth_access_token.rb
+++ b/app/models/oauth_access_token.rb
@@ -26,4 +26,13 @@ class OauthAccessToken < Doorkeeper::AccessToken
super
end
+
+ # Override Doorkeeper::AccessToken.matching_token_for since we
+ # have `reuse_access_tokens` disabled and we also hash tokens.
+ # This ensures we don't accidentally return a hashed token value.
+ def self.matching_token_for(application, resource_owner, scopes)
+ return if Feature.enabled?(:hash_oauth_tokens)
+
+ super
+ end
end
diff --git a/app/models/onboarding/completion.rb b/app/models/onboarding/completion.rb
new file mode 100644
index 00000000000..49fdb102209
--- /dev/null
+++ b/app/models/onboarding/completion.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Onboarding
+ class Completion
+ include Gitlab::Utils::StrongMemoize
+ include Gitlab::Experiment::Dsl
+
+ ACTION_ISSUE_IDS = {
+ pipeline_created: 7,
+ trial_started: 2,
+ required_mr_approvals_enabled: 11,
+ code_owners_enabled: 10
+ }.freeze
+
+ ACTION_PATHS = [
+ :issue_created,
+ :git_write,
+ :merge_request_created,
+ :user_added
+ ].freeze
+
+ def initialize(namespace, current_user = nil)
+ @namespace = namespace
+ @current_user = current_user
+ end
+
+ def percentage
+ return 0 unless onboarding_progress
+
+ attributes = onboarding_progress.attributes.symbolize_keys
+
+ total_actions = action_columns.count
+ completed_actions = action_columns.count { |column| attributes[column].present? }
+
+ (completed_actions.to_f / total_actions * 100).round
+ end
+
+ private
+
+ def onboarding_progress
+ strong_memoize(:onboarding_progress) do
+ ::Onboarding::Progress.find_by(namespace: namespace)
+ end
+ end
+
+ def action_columns
+ strong_memoize(:action_columns) do
+ tracked_actions.map { |action_key| ::Onboarding::Progress.column_name(action_key) }
+ end
+ end
+
+ def tracked_actions
+ ACTION_ISSUE_IDS.keys + ACTION_PATHS + deploy_section_tracked_actions
+ end
+
+ def deploy_section_tracked_actions
+ experiment(
+ :security_actions_continuous_onboarding,
+ namespace: namespace,
+ user: current_user,
+ sticky_to: current_user
+ ) do |e|
+ e.control { [:security_scan_enabled] }
+ e.candidate { [:license_scanning_run, :secure_dependency_scanning_run, :secure_dast_run] }
+ end.run
+ end
+
+ attr_reader :namespace, :current_user
+ end
+end
diff --git a/app/models/onboarding/learn_gitlab.rb b/app/models/onboarding/learn_gitlab.rb
new file mode 100644
index 00000000000..d7a189ed6e2
--- /dev/null
+++ b/app/models/onboarding/learn_gitlab.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Onboarding
+ class LearnGitlab
+ PROJECT_NAME = 'Learn GitLab'
+ PROJECT_NAME_ULTIMATE_TRIAL = 'Learn GitLab - Ultimate trial'
+ BOARD_NAME = 'GitLab onboarding'
+ LABEL_NAME = 'Novice'
+
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def available?
+ project && board && label
+ end
+
+ def project
+ @project ||= current_user.projects.find_by_name([PROJECT_NAME, PROJECT_NAME_ULTIMATE_TRIAL])
+ end
+
+ def board
+ return unless project
+
+ @board ||= project.boards.find_by_name(BOARD_NAME)
+ end
+
+ def label
+ return unless project
+
+ @label ||= project.labels.find_by_name(LABEL_NAME)
+ end
+
+ private
+
+ attr_reader :current_user
+ end
+end
diff --git a/app/models/onboarding/progress.rb b/app/models/onboarding/progress.rb
new file mode 100644
index 00000000000..ecc78418256
--- /dev/null
+++ b/app/models/onboarding/progress.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+module Onboarding
+ class Progress < ApplicationRecord
+ self.table_name = 'onboarding_progresses'
+
+ belongs_to :namespace, optional: false
+
+ validate :namespace_is_root_namespace
+
+ ACTIONS = [
+ :git_pull,
+ :git_write,
+ :merge_request_created,
+ :pipeline_created,
+ :user_added,
+ :trial_started,
+ :subscription_created,
+ :required_mr_approvals_enabled,
+ :code_owners_enabled,
+ :scoped_label_created,
+ :security_scan_enabled,
+ :issue_created,
+ :issue_auto_closed,
+ :repository_imported,
+ :repository_mirrored,
+ :secure_dependency_scanning_run,
+ :secure_container_scanning_run,
+ :secure_dast_run,
+ :secure_secret_detection_run,
+ :secure_coverage_fuzzing_run,
+ :secure_api_fuzzing_run,
+ :secure_cluster_image_scanning_run,
+ :license_scanning_run
+ ].freeze
+
+ scope :incomplete_actions, ->(actions) do
+ Array.wrap(actions).inject(self) { |scope, action| scope.where(column_name(action) => nil) }
+ end
+
+ scope :completed_actions, ->(actions) do
+ Array.wrap(actions).inject(self) { |scope, action| scope.where.not(column_name(action) => nil) }
+ end
+
+ scope :completed_actions_with_latest_in_range, ->(actions, range) do
+ actions = Array(actions)
+ if actions.size == 1
+ where(column_name(actions[0]) => range)
+ else
+ action_columns = actions.map { |action| arel_table[column_name(action)] }
+ completed_actions(actions).where(Arel::Nodes::NamedFunction.new('GREATEST', action_columns).between(range))
+ end
+ end
+
+ class << self
+ def onboard(namespace)
+ return unless root_namespace?(namespace)
+
+ create(namespace: namespace)
+ end
+
+ def onboarding?(namespace)
+ where(namespace: namespace).any?
+ end
+
+ def register(namespace, actions)
+ actions = Array(actions)
+ return unless root_namespace?(namespace) && actions.difference(ACTIONS).empty?
+
+ onboarding_progress = find_by(namespace: namespace)
+ return unless onboarding_progress
+
+ now = Time.current
+ nil_actions = actions.select { |action| onboarding_progress[column_name(action)].nil? }
+ return if nil_actions.empty?
+
+ updates = nil_actions.inject({}) { |sum, action| sum.merge!({ column_name(action) => now }) }
+ onboarding_progress.update!(updates)
+ end
+
+ def completed?(namespace, action)
+ return unless root_namespace?(namespace) && ACTIONS.include?(action)
+
+ action_column = column_name(action)
+ where(namespace: namespace).where.not(action_column => nil).exists?
+ end
+
+ def not_completed?(namespace_id, action)
+ return unless ACTIONS.include?(action)
+
+ action_column = column_name(action)
+ exists?(namespace_id: namespace_id, action_column => nil)
+ end
+
+ def column_name(action)
+ :"#{action}_at"
+ end
+
+ private
+
+ def root_namespace?(namespace)
+ namespace&.root?
+ end
+ end
+
+ def number_of_completed_actions
+ attributes.extract!(*ACTIONS.map { |action| self.class.column_name(action).to_s }).compact!.size
+ end
+
+ private
+
+ def namespace_is_root_namespace
+ return unless namespace
+
+ errors.add(:namespace, _('must be a root namespace')) if namespace.has_parent?
+ end
+ end
+end
diff --git a/app/models/onboarding_progress.rb b/app/models/onboarding_progress.rb
deleted file mode 100644
index e5851c5cfc5..00000000000
--- a/app/models/onboarding_progress.rb
+++ /dev/null
@@ -1,114 +0,0 @@
-# frozen_string_literal: true
-
-class OnboardingProgress < ApplicationRecord
- belongs_to :namespace, optional: false
-
- validate :namespace_is_root_namespace
-
- ACTIONS = [
- :git_pull,
- :git_write,
- :merge_request_created,
- :pipeline_created,
- :user_added,
- :trial_started,
- :subscription_created,
- :required_mr_approvals_enabled,
- :code_owners_enabled,
- :scoped_label_created,
- :security_scan_enabled,
- :issue_created,
- :issue_auto_closed,
- :repository_imported,
- :repository_mirrored,
- :secure_dependency_scanning_run,
- :secure_container_scanning_run,
- :secure_dast_run,
- :secure_secret_detection_run,
- :secure_coverage_fuzzing_run,
- :secure_api_fuzzing_run,
- :secure_cluster_image_scanning_run,
- :license_scanning_run
- ].freeze
-
- scope :incomplete_actions, -> (actions) do
- Array.wrap(actions).inject(self) { |scope, action| scope.where(column_name(action) => nil) }
- end
-
- scope :completed_actions, -> (actions) do
- Array.wrap(actions).inject(self) { |scope, action| scope.where.not(column_name(action) => nil) }
- end
-
- scope :completed_actions_with_latest_in_range, -> (actions, range) do
- actions = Array(actions)
- if actions.size == 1
- where(column_name(actions[0]) => range)
- else
- action_columns = actions.map { |action| arel_table[column_name(action)] }
- completed_actions(actions).where(Arel::Nodes::NamedFunction.new('GREATEST', action_columns).between(range))
- end
- end
-
- class << self
- def onboard(namespace)
- return unless root_namespace?(namespace)
-
- create(namespace: namespace)
- end
-
- def onboarding?(namespace)
- where(namespace: namespace).any?
- end
-
- def register(namespace, actions)
- actions = Array(actions)
- return unless root_namespace?(namespace) && actions.difference(ACTIONS).empty?
-
- onboarding_progress = find_by(namespace: namespace)
- return unless onboarding_progress
-
- now = Time.current
- nil_actions = actions.select { |action| onboarding_progress[column_name(action)].nil? }
- return if nil_actions.empty?
-
- updates = nil_actions.inject({}) { |sum, action| sum.merge!({ column_name(action) => now }) }
- onboarding_progress.update!(updates)
- end
-
- def completed?(namespace, action)
- return unless root_namespace?(namespace) && ACTIONS.include?(action)
-
- action_column = column_name(action)
- where(namespace: namespace).where.not(action_column => nil).exists?
- end
-
- def not_completed?(namespace_id, action)
- return unless ACTIONS.include?(action)
-
- action_column = column_name(action)
- where(namespace_id: namespace_id).where(action_column => nil).exists?
- end
-
- def column_name(action)
- :"#{action}_at"
- end
-
- private
-
- def root_namespace?(namespace)
- namespace && namespace.root?
- end
- end
-
- def number_of_completed_actions
- attributes.extract!(*ACTIONS.map { |action| self.class.column_name(action).to_s }).compact!.size
- end
-
- private
-
- def namespace_is_root_namespace
- return unless namespace
-
- errors.add(:namespace, _('must be a root namespace')) if namespace.has_parent?
- end
-end
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index afd55b4f143..b4c09d99bb0 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -22,7 +22,8 @@ class Packages::Package < ApplicationRecord
debian: 9,
rubygems: 10,
helm: 11,
- terraform_module: 12
+ terraform_module: 12,
+ rpm: 13
}
enum status: { default: 0, hidden: 1, processing: 2, error: 3, pending_destruction: 4 }
@@ -43,6 +44,7 @@ class Packages::Package < ApplicationRecord
has_one :nuget_metadatum, inverse_of: :package, class_name: 'Packages::Nuget::Metadatum'
has_one :composer_metadatum, inverse_of: :package, class_name: 'Packages::Composer::Metadatum'
has_one :rubygems_metadatum, inverse_of: :package, class_name: 'Packages::Rubygems::Metadatum'
+ has_one :rpm_metadatum, inverse_of: :package, class_name: 'Packages::Rpm::Metadatum'
has_one :npm_metadatum, inverse_of: :package, class_name: 'Packages::Npm::Metadatum'
has_many :build_infos, inverse_of: :package
has_many :pipelines, through: :build_infos, disable_joins: true
@@ -242,22 +244,23 @@ class Packages::Package < ApplicationRecord
reverse_order_direction = direction == :asc ? desc_order_expression : asc_order_expression
arel_order_classes = ::Gitlab::Pagination::Keyset::ColumnOrderDefinition::AREL_ORDER_CLASSES.invert
- ::Gitlab::Pagination::Keyset::Order.build([
- ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: "#{join_table}_#{column_name}",
- column_expression: join_class.arel_table[column_name],
- order_expression: order_direction,
- reversed_order_expression: reverse_order_direction,
- order_direction: direction,
- distinct: false,
- add_to_projections: true
- ),
- ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'id',
- order_expression: arel_order_classes[direction].new(Packages::Package.arel_table[:id]),
- add_to_projections: true
- )
- ])
+ ::Gitlab::Pagination::Keyset::Order.build(
+ [
+ ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: "#{join_table}_#{column_name}",
+ column_expression: join_class.arel_table[column_name],
+ order_expression: order_direction,
+ reversed_order_expression: reverse_order_direction,
+ order_direction: direction,
+ distinct: false,
+ add_to_projections: true
+ ),
+ ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: arel_order_classes[direction].new(Packages::Package.arel_table[:id]),
+ add_to_projections: true
+ )
+ ])
end
def versions
@@ -330,6 +333,12 @@ class Packages::Package < ApplicationRecord
name.gsub(/#{Gitlab::Regex::Packages::PYPI_NORMALIZED_NAME_REGEX_STRING}/o, '-').downcase
end
+ def touch_last_downloaded_at
+ ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
+ update_column(:last_downloaded_at, Time.zone.now)
+ end
+ end
+
private
def composer_tag_version?
diff --git a/app/models/packages/policies/group.rb b/app/models/packages/policies/group.rb
new file mode 100644
index 00000000000..66cd361f2ed
--- /dev/null
+++ b/app/models/packages/policies/group.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Packages
+ module Policies
+ class Group
+ attr_accessor :group
+
+ delegate_missing_to :group
+
+ def initialize(group)
+ @group = group
+ end
+ end
+ end
+end
diff --git a/app/models/packages/policies/project.rb b/app/models/packages/policies/project.rb
new file mode 100644
index 00000000000..a5c6703be42
--- /dev/null
+++ b/app/models/packages/policies/project.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Packages
+ module Policies
+ class Project
+ attr_accessor :project
+
+ delegate_missing_to :project
+
+ def initialize(project)
+ @project = project
+ end
+ end
+ end
+end
diff --git a/app/models/packages/rpm.rb b/app/models/packages/rpm.rb
new file mode 100644
index 00000000000..fc66e7ec5c8
--- /dev/null
+++ b/app/models/packages/rpm.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+module Packages
+ module Rpm
+ def self.table_name_prefix
+ 'packages_rpm_'
+ end
+ end
+end
diff --git a/app/models/packages/rpm/metadatum.rb b/app/models/packages/rpm/metadatum.rb
new file mode 100644
index 00000000000..07361995a12
--- /dev/null
+++ b/app/models/packages/rpm/metadatum.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Packages
+ module Rpm
+ class Metadatum < ApplicationRecord
+ self.primary_key = :package_id
+
+ belongs_to :package, -> { where(package_type: :rpm) }, inverse_of: :rpm_metadatum
+
+ validates :package, presence: true
+
+ validates :epoch,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ validates :release,
+ presence: true,
+ length: { maximum: 128 }
+
+ validates :summary,
+ presence: true,
+ length: { maximum: 1000 }
+
+ validates :description,
+ presence: true,
+ length: { maximum: 5000 }
+
+ validates :arch,
+ presence: true,
+ length: { maximum: 255 }
+
+ validates :license,
+ allow_nil: true,
+ length: { maximum: 1000 }
+
+ validates :url,
+ allow_nil: true,
+ length: { maximum: 1000 }
+
+ validate :rpm_package_type
+
+ private
+
+ def rpm_package_type
+ return if package&.rpm?
+
+ errors.add(:base, _('Package type must be RPM'))
+ end
+ end
+ end
+end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 2e25839c47f..16d5492a65e 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -33,6 +33,7 @@ class PagesDomain < ApplicationRecord
validate :validate_pages_domain
validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? }
validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? }
+ validate :validate_custom_domain_count_per_project, on: :create
default_value_for(:auto_ssl_enabled, allows_nil: false) { ::Gitlab::LetsEncrypt.enabled? }
default_value_for :scope, allows_nil: false, value: :project
@@ -57,6 +58,7 @@ class PagesDomain < ApplicationRecord
where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold))))
end
+ scope :verified, -> { where.not(verified_at: nil) }
scope :need_auto_ssl_renewal, -> do
enabled_and_not_failed = where(auto_ssl_enabled: true, auto_ssl_failed: false)
@@ -224,6 +226,16 @@ class PagesDomain < ApplicationRecord
self.auto_ssl_failed = false
end
+ def validate_custom_domain_count_per_project
+ return unless project
+
+ unless project.can_create_custom_domains?
+ self.errors.add(
+ :base,
+ _("This project reached the limit of custom domains. (Max %d)") % Gitlab::CurrentSettings.max_pages_custom_domains_per_project)
+ end
+ end
+
private
def pages_deployed?
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 7e6e366f8da..9ed25c56ed6 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -24,6 +24,8 @@ class PersonalAccessToken < ApplicationRecord
scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) }
scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) }
scope :inactive, -> { where("revoked = true OR expires_at < CURRENT_DATE") }
+ scope :created_before, -> (date) { where("personal_access_tokens.created_at < :date", date: date) }
+ scope :last_used_before_or_unused, -> (date) { where("personal_access_tokens.created_at < :date AND (last_used_at < :date OR last_used_at IS NULL)", date: date) }
scope :with_impersonation, -> { where(impersonation: true) }
scope :without_impersonation, -> { where(impersonation: false) }
scope :revoked, -> { where(revoked: true) }
diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb
index 3461104ae35..f22a63ee980 100644
--- a/app/models/pool_repository.rb
+++ b/app/models/pool_repository.rb
@@ -81,8 +81,8 @@ class PoolRepository < ApplicationRecord
object_pool.link(repository.raw)
end
- def unlink_repository(repository)
- repository.disconnect_alternates
+ def unlink_repository(repository, disconnect: true)
+ repository.disconnect_alternates if disconnect
if member_projects.where.not(id: repository.project.id).exists?
true
diff --git a/app/models/preloaders/environments/deployment_preloader.rb b/app/models/preloaders/environments/deployment_preloader.rb
index 251d1837f19..84aa7bc834f 100644
--- a/app/models/preloaders/environments/deployment_preloader.rb
+++ b/app/models/preloaders/environments/deployment_preloader.rb
@@ -41,11 +41,11 @@ module Preloaders
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
+ next unless 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
diff --git a/app/models/preloaders/group_policy_preloader.rb b/app/models/preloaders/group_policy_preloader.rb
index 44030140ce3..23632a9b6c2 100644
--- a/app/models/preloaders/group_policy_preloader.rb
+++ b/app/models/preloaders/group_policy_preloader.rb
@@ -17,4 +17,4 @@ module Preloaders
end
end
-Preloaders::GroupPolicyPreloader.prepend_mod_with('Preloaders::GroupPolicyPreloader')
+Preloaders::GroupPolicyPreloader.prepend_mod
diff --git a/app/models/preloaders/project_policy_preloader.rb b/app/models/preloaders/project_policy_preloader.rb
new file mode 100644
index 00000000000..fe9db3464c7
--- /dev/null
+++ b/app/models/preloaders/project_policy_preloader.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Preloaders
+ class ProjectPolicyPreloader
+ def initialize(projects, current_user)
+ @projects = projects
+ @current_user = current_user
+ end
+
+ def execute
+ return if projects.is_a?(ActiveRecord::NullRelation)
+
+ ActiveRecord::Associations::Preloader.new.preload(projects, { group: :route, namespace: :owner })
+ ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute
+ end
+
+ private
+
+ attr_reader :projects, :current_user
+ end
+end
+
+Preloaders::ProjectPolicyPreloader.prepend_mod
diff --git a/app/models/preloaders/project_root_ancestor_preloader.rb b/app/models/preloaders/project_root_ancestor_preloader.rb
new file mode 100644
index 00000000000..8d04e71774c
--- /dev/null
+++ b/app/models/preloaders/project_root_ancestor_preloader.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Preloaders
+ class ProjectRootAncestorPreloader
+ def initialize(projects, namespace_sti_name = :namespace, root_ancestor_preloads = [])
+ @projects = projects
+ @namespace_sti_name = namespace_sti_name
+ @root_ancestor_preloads = root_ancestor_preloads
+ end
+
+ def execute
+ return if @projects.is_a?(ActiveRecord::NullRelation)
+ return unless ::Feature.enabled?(:use_traversal_ids)
+
+ root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id")
+ .select('namespaces.*, root_query.id as source_id')
+
+ root_query = root_query.preload(*@root_ancestor_preloads) if @root_ancestor_preloads.any?
+
+ root_ancestors_by_id = root_query.group_by(&:source_id)
+
+ ActiveRecord::Associations::Preloader.new.preload(@projects, :namespace)
+ @projects.each do |project|
+ project.namespace.root_ancestor = root_ancestors_by_id[project.id]&.first
+ end
+ end
+
+ private
+
+ def join_sql
+ @projects
+ .joins(@namespace_sti_name)
+ .select('projects.id, namespaces.traversal_ids[1] as root_id')
+ .to_sql
+ end
+ end
+end
diff --git a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb
index 99a31a620c5..f32184f168d 100644
--- a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb
+++ b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb
@@ -51,4 +51,4 @@ module Preloaders
end
end
-# Preloaders::UsersMaxAccessLevelInProjectsPreloader.prepend_mod
+Preloaders::UsersMaxAccessLevelInProjectsPreloader.prepend_mod
diff --git a/app/models/project.rb b/app/models/project.rb
index 0c49cc24a8d..c5fad189f87 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -46,13 +46,9 @@ class Project < ApplicationRecord
extend Gitlab::ConfigHelper
- ignore_columns :container_registry_enabled, remove_after: '2021-09-22', remove_with: '14.4'
-
BoardLimitExceeded = Class.new(StandardError)
ExportLimitExceeded = Class.new(StandardError)
- ignore_columns :mirror_last_update_at, :mirror_last_successful_update_at, remove_after: '2021-09-22', remove_with: '14.4'
- ignore_columns :pull_mirror_branch_prefix, remove_after: '2021-09-22', remove_with: '14.4'
ignore_columns :build_coverage_regex, remove_after: '2022-10-22', remove_with: '15.5'
STATISTICS_ATTRIBUTE = 'repositories_count'
@@ -123,6 +119,7 @@ class Project < ApplicationRecord
before_validation :ensure_project_namespace_in_sync
before_validation :set_package_registry_access_level, if: :packages_enabled_changed?
+ before_validation :remove_leading_spaces_on_name
after_save :update_project_statistics, if: :saved_change_to_namespace_id?
@@ -453,7 +450,7 @@ class Project < ApplicationRecord
:metrics_dashboard_access_level, :analytics_access_level,
:operations_access_level, :security_and_compliance_access_level,
:container_registry_access_level, :environments_access_level, :feature_flags_access_level,
- :releases_access_level,
+ :monitor_access_level, :releases_access_level,
to: :project_feature, allow_nil: true
delegate :show_default_award_emojis, :show_default_award_emojis=,
@@ -461,6 +458,9 @@ class Project < ApplicationRecord
:warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=,
to: :project_setting, allow_nil: true
+ delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email?,
+ to: :project_setting
+
delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting
delegate :squash_option, :squash_option=, to: :project_setting
delegate :mr_default_target_self, :mr_default_target_self=, to: :project_setting
@@ -565,26 +565,29 @@ class Project < ApplicationRecord
scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) }
scope :sorted_by_similarity_desc, -> (search, include_in_select: false) do
- order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [
- { column: arel_table["path"], multiplier: 1 },
- { column: arel_table["name"], multiplier: 0.7 },
- { column: arel_table["description"], multiplier: 0.2 }
- ])
-
- order = Gitlab::Pagination::Keyset::Order.build([
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'similarity',
- column_expression: order_expression,
- order_expression: order_expression.desc,
- order_direction: :desc,
- distinct: false,
- add_to_projections: true
- ),
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'id',
- order_expression: Project.arel_table[:id].desc
- )
- ])
+ order_expression = Gitlab::Database::SimilarityScore.build_expression(
+ search: search,
+ rules: [
+ { column: arel_table["path"], multiplier: 1 },
+ { column: arel_table["name"], multiplier: 0.7 },
+ { column: arel_table["description"], multiplier: 0.2 }
+ ])
+
+ order = Gitlab::Pagination::Keyset::Order.build(
+ [
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'similarity',
+ column_expression: order_expression,
+ order_expression: order_expression.desc,
+ order_direction: :desc,
+ distinct: false,
+ add_to_projections: true
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: Project.arel_table[:id].desc
+ )
+ ])
order.apply_cursor_conditions(reorder(order))
end
@@ -611,7 +614,7 @@ class Project < ApplicationRecord
scope :include_integration, -> (integration_association_name) { includes(integration_association_name) }
scope :with_integration, -> (integration_class) { joins(:integrations).merge(integration_class.all) }
scope :with_active_integration, -> (integration_class) { with_integration(integration_class).merge(integration_class.active) }
- scope :with_shared_runners, -> { where(shared_runners_enabled: true) }
+ scope :with_shared_runners_enabled, -> { where(shared_runners_enabled: true) }
scope :inside_path, ->(path) do
# We need routes alias rs for JOIN so it does not conflict with
# includes(:route) which we use in ProjectsFinder.
@@ -1163,7 +1166,7 @@ class Project < ApplicationRecord
latest_pipeline = ci_pipelines.latest_successful_for_ref(ref)
return unless latest_pipeline
- latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name)
+ latest_pipeline.build_with_artifacts_in_self_and_project_descendants(job_name)
end
def latest_successful_build_for_sha(job_name, sha)
@@ -1172,7 +1175,7 @@ class Project < ApplicationRecord
latest_pipeline = ci_pipelines.latest_successful_for_sha(sha)
return unless latest_pipeline
- latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name)
+ latest_pipeline.build_with_artifacts_in_self_and_project_descendants(job_name)
end
def latest_successful_build_for_ref!(job_name, ref = default_branch)
@@ -1564,9 +1567,7 @@ class Project < ApplicationRecord
end
def disabled_integrations
- disabled_integrations = []
- disabled_integrations << 'shimo' unless Feature.enabled?(:shimo_integration, self)
- disabled_integrations
+ []
end
def find_or_initialize_integration(name)
@@ -2369,28 +2370,6 @@ class Project < ApplicationRecord
.first
end
- def ci_variables_for(ref:, environment: nil)
- cache_key = "ci_variables_for:project:#{self&.id}:ref:#{ref}:environment:#{environment}"
-
- ::Gitlab::SafeRequestStore.fetch(cache_key) do
- uncached_ci_variables_for(ref: ref, environment: environment)
- end
- end
-
- def uncached_ci_variables_for(ref:, environment: nil)
- result = if protected_for?(ref)
- variables
- else
- variables.unprotected
- end
-
- if environment
- result.on_environment(environment)
- else
- result.where(environment_scope: '*')
- end
- end
-
def protected_for?(ref)
raise Repository::AmbiguousRefError if repository.ambiguous_ref?(ref)
@@ -2582,10 +2561,7 @@ class Project < ApplicationRecord
def badges
return project_badges unless group
- Badge.from_union([
- project_badges,
- GroupBadge.where(group: group.self_and_ancestors)
- ])
+ Badge.from_union([project_badges, GroupBadge.where(group: group.self_and_ancestors)])
end
def merge_requests_allowing_push_to_user(user)
@@ -2631,11 +2607,7 @@ class Project < ApplicationRecord
def gitlab_deploy_token
strong_memoize(:gitlab_deploy_token) do
- if Feature.enabled?(:ci_variable_for_group_gitlab_deploy_token, self)
- deploy_tokens.gitlab_deploy_token || group&.gitlab_deploy_token
- else
- deploy_tokens.gitlab_deploy_token
- end
+ deploy_tokens.gitlab_deploy_token || group&.gitlab_deploy_token
end
end
@@ -2693,7 +2665,12 @@ class Project < ApplicationRecord
end
def leave_pool_repository
- pool_repository&.unlink_repository(repository) && update_column(:pool_repository_id, nil)
+ return if pool_repository.blank?
+
+ # Disconnecting the repository can be expensive, so let's skip it if
+ # this repository is being deleted anyway.
+ pool_repository.unlink_repository(repository, disconnect: !pending_delete?)
+ update_column(:pool_repository_id, nil)
end
def link_pool_repository
@@ -3045,10 +3022,24 @@ class Project < ApplicationRecord
licensed_feature_available?(:security_training)
end
+ def packages_policy_subject
+ if Feature.enabled?(:read_package_policy_rule, group)
+ ::Packages::Policies::Project.new(self)
+ else
+ self
+ end
+ end
+
def destroy_deployment_by_id(deployment_id)
deployments.where(id: deployment_id).fast_destroy_all
end
+ def can_create_custom_domains?
+ return true if Gitlab::CurrentSettings.max_pages_custom_domains_per_project == 0
+
+ pages_domains.count < Gitlab::CurrentSettings.max_pages_custom_domains_per_project
+ end
+
private
# overridden in EE
@@ -3300,6 +3291,10 @@ class Project < ApplicationRecord
end
end
+ def remove_leading_spaces_on_name
+ name&.lstrip!
+ end
+
def set_package_registry_access_level
return if !project_feature || project_feature.package_registry_access_level_changed?
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 8623e477c06..dad8aaf0625 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -17,6 +17,7 @@ class ProjectFeature < ApplicationRecord
pages
metrics_dashboard
analytics
+ monitor
operations
security_and_compliance
container_registry
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index 59d2e3deb4f..f5c346eda30 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ProjectSetting < ApplicationRecord
+ include ::Gitlab::Utils::StrongMemoize
+
ALLOWED_TARGET_PLATFORMS = %w(ios osx tvos watchos android).freeze
belongs_to :project, inverse_of: :project_setting
@@ -47,6 +49,15 @@ class ProjectSetting < ApplicationRecord
end
end
+ def show_diff_preview_in_email?
+ if project.group
+ super && project.group&.show_diff_preview_in_email?
+ else
+ !!super
+ end
+ end
+ strong_memoize_attr :show_diff_preview_in_email
+
private
def validates_mr_default_target_self
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index a0af1b47d01..a91e0291438 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -11,9 +11,10 @@ class ProjectStatistics < ApplicationRecord
default_value_for :snippets_size, 0
counter_attribute :build_artifacts_size
- counter_attribute :storage_size
counter_attribute_after_flush do |project_statistic|
+ project_statistic.refresh_storage_size!
+
Namespaces::ScheduleAggregationWorker.perform_async(project_statistic.namespace_id)
end
@@ -21,7 +22,6 @@ class ProjectStatistics < ApplicationRecord
COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size, :uploads_size, :container_registry_size].freeze
INCREMENTABLE_COLUMNS = {
- build_artifacts_size: %i[storage_size],
packages_size: %i[storage_size],
pipeline_artifacts_size: %i[storage_size],
snippets_size: %i[storage_size]
@@ -109,21 +109,25 @@ class ProjectStatistics < ApplicationRecord
self.storage_size = storage_size
end
- # Since this incremental update method does not call update_storage_size above,
- # we have to update the storage_size here as additional column.
- # Additional columns are updated depending on key => [columns], which allows
- # to update statistics which are and also those which aren't included in storage_size
- # or any other additional summary column in the future.
+ def refresh_storage_size!
+ update_storage_size
+ save!
+ end
+
+ # Since this incremental update method does not call update_storage_size above through before_save,
+ # we have to update the storage_size separately.
+ #
+ # For counter attributes, storage_size will be refreshed after the counter is flushed,
+ # through counter_attribute_after_flush
+ #
+ # For non-counter attributes, storage_size is updated depending on key => [columns] in INCREMENTABLE_COLUMNS
def self.increment_statistic(project, key, amount)
- raise ArgumentError, "Cannot increment attribute: #{key}" unless INCREMENTABLE_COLUMNS.key?(key)
+ raise ArgumentError, "Cannot increment attribute: #{key}" unless incrementable_attribute?(key)
return if amount == 0
project.statistics.try do |project_statistics|
- if project_statistics.counter_attribute_enabled?(key)
- statistics_to_increment = [key] + INCREMENTABLE_COLUMNS[key].to_a
- statistics_to_increment.each do |statistic|
- project_statistics.delayed_increment_counter(statistic, amount)
- end
+ if counter_attribute_enabled?(key)
+ project_statistics.delayed_increment_counter(key, amount)
else
legacy_increment_statistic(project, key, amount)
end
@@ -149,6 +153,10 @@ class ProjectStatistics < ApplicationRecord
update_all(updates.join(', '))
end
+ def self.incrementable_attribute?(key)
+ INCREMENTABLE_COLUMNS.key?(key) || counter_attribute_enabled?(key)
+ end
+
private
def schedule_namespace_aggregation_worker
diff --git a/app/models/projects/build_artifacts_size_refresh.rb b/app/models/projects/build_artifacts_size_refresh.rb
index dee4afdefa6..e66e1d5b42f 100644
--- a/app/models/projects/build_artifacts_size_refresh.rb
+++ b/app/models/projects/build_artifacts_size_refresh.rb
@@ -2,6 +2,7 @@
module Projects
class BuildArtifactsSizeRefresh < ApplicationRecord
+ include AfterCommitQueue
include BulkInsertSafe
STALE_WINDOW = 2.hours
@@ -52,6 +53,8 @@ module Projects
scope :remaining, -> { with_state(:created, :pending).or(stale) }
scope :processing_queue, -> { remaining.order(state: :desc) }
+ after_destroy :schedule_namespace_aggregation_worker
+
def self.enqueue_refresh(projects)
now = Time.zone.now
@@ -93,5 +96,13 @@ module Projects
def started?
!created?
end
+
+ private
+
+ def schedule_namespace_aggregation_worker
+ run_after_commit do
+ Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id)
+ end
+ end
end
end
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index b0f138714a0..3155eede2bd 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -18,9 +18,11 @@ module Projects
scope :without_assigned_projects, -> { where(total_projects_count: 0) }
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'] }
- ])
+ order_expression = Gitlab::Database::SimilarityScore.build_expression(
+ search: search,
+ rules: [
+ { column: arel_table['name'] }
+ ])
reorder(order_expression.desc, arel_table['non_private_projects_count'].desc, arel_table['id'])
end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 76c277e4b86..b3a918d8952 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -25,10 +25,12 @@ class ProtectedBranch < ApplicationRecord
end
# Check if branch name is marked as protected in the system
- def self.protected?(project, ref_name, dry_run: true)
+ def self.protected?(project, ref_name)
return true if project.empty_repo? && project.default_branch_protected?
return false if ref_name.blank?
+ dry_run = Feature.disabled?(:rely_on_protected_branches_cache, project)
+
new_cache_result = new_cache(project, ref_name, dry_run: dry_run)
return new_cache_result unless new_cache_result.nil?
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 26c3b01a46e..ee1bea0e8d2 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -194,6 +194,18 @@ class Repository
CommitCollection.new(container, commits, ref)
end
+ def list_commits_by(query, ref, author: nil, before: nil, after: nil, limit: 1000)
+ return [] unless exists?
+ return [] unless has_visible_content?
+ return [] unless query.present? && ref.present?
+
+ commits = raw_repository.list_commits_by(
+ query, ref, author: author, before: before, after: after, limit: limit).map do |c|
+ commit(c)
+ end
+ CommitCollection.new(container, commits, ref)
+ end
+
def find_branch(name)
raw_repository.find_branch(name)
end
@@ -779,8 +791,8 @@ class Repository
raw_repository.branch_names_contains_sha(sha)
end
- def tag_names_contains(sha)
- raw_repository.tag_names_contains_sha(sha)
+ def tag_names_contains(sha, limit: 0)
+ raw_repository.tag_names_contains_sha(sha, limit: limit)
end
def local_branches
@@ -796,7 +808,7 @@ class Repository
def create_dir(user, path, **options)
options[:actions] = [{ action: :create_dir, file_path: path }]
- multi_action(user, **options)
+ commit_files(user, **options)
end
def create_file(user, path, content, **options)
@@ -808,7 +820,7 @@ class Repository
options[:actions].push({ action: :chmod, file_path: path, execute_filemode: execute_filemode })
end
- multi_action(user, **options)
+ commit_files(user, **options)
end
def update_file(user, path, content, **options)
@@ -823,13 +835,13 @@ class Repository
options[:actions].push({ action: :chmod, file_path: path, execute_filemode: execute_filemode })
end
- multi_action(user, **options)
+ commit_files(user, **options)
end
def delete_file(user, path, **options)
options[:actions] = [{ action: :delete, file_path: path }]
- multi_action(user, **options)
+ commit_files(user, **options)
end
def with_cache_hooks
@@ -843,14 +855,14 @@ class Repository
result.newrev
end
- def multi_action(user, **options)
+ def commit_files(user, **options)
start_project = options.delete(:start_project)
if start_project
options[:start_repository] = start_project.repository.raw_repository
end
- with_cache_hooks { raw.multi_action(user, **options) }
+ with_cache_hooks { raw.commit_files(user, **options) }
end
def merge(user, source_sha, merge_request, message)
diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb
index 689a9d8a8ae..6ebb9d5f176 100644
--- a/app/models/resource_state_event.rb
+++ b/app/models/resource_state_event.rb
@@ -3,8 +3,9 @@
class ResourceStateEvent < ResourceEvent
include IssueResourceEvent
include MergeRequestResourceEvent
+ include Importable
- validate :exactly_one_issuable
+ validate :exactly_one_issuable, unless: :importing?
belongs_to :source_merge_request, class_name: 'MergeRequest', foreign_key: :source_merge_request_id
@@ -32,9 +33,9 @@ class ResourceStateEvent < ResourceEvent
case state
when 'closed'
- issue_usage_counter.track_issue_closed_action(author: user)
+ issue_usage_counter.track_issue_closed_action(author: user, project: issue.project)
when 'reopened'
- issue_usage_counter.track_issue_reopened_action(author: user)
+ issue_usage_counter.track_issue_reopened_action(author: user, project: issue.project)
else
# no-op, nothing to do, not a state we're tracking
end
diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb
index db87ff09159..26bf2a225d4 100644
--- a/app/models/resource_timebox_event.rb
+++ b/app/models/resource_timebox_event.rb
@@ -5,8 +5,9 @@ class ResourceTimeboxEvent < ResourceEvent
include IssueResourceEvent
include MergeRequestResourceEvent
+ include Importable
- validate :exactly_one_issuable
+ validate :exactly_one_issuable, unless: :importing?
enum action: {
add: 1,
@@ -34,7 +35,8 @@ class ResourceTimeboxEvent < ResourceEvent
case self
when ResourceMilestoneEvent
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user,
+ project: issue.project)
else
# no-op
end
diff --git a/app/models/route.rb b/app/models/route.rb
index 2f6b0a8e8f1..f2fe1664f9e 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -39,17 +39,17 @@ class Route < ApplicationRecord
attributes[:name] = route.name.sub(name_before_last_save, name)
end
- if attributes.present?
- old_path = route.path
+ next if attributes.empty?
- # Callbacks must be run manually
- route.update_columns(attributes.merge(updated_at: Time.current))
+ old_path = route.path
- # We are not calling route.delete_conflicting_redirects here, in hopes
- # of avoiding deadlocks. The parent (self, in this method) already
- # called it, which deletes conflicts for all descendants.
- route.create_redirect(old_path) if attributes[:path]
- end
+ # Callbacks must be run manually
+ route.update_columns(attributes.merge(updated_at: Time.current))
+
+ # We are not calling route.delete_conflicting_redirects here, in hopes
+ # of avoiding deadlocks. The parent (self, in this method) already
+ # called it, which deletes conflicts for all descendants.
+ route.create_redirect(old_path) if attributes[:path]
end
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 943d09d983b..9b7c37dd23e 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -84,7 +84,7 @@ class Snippet < ApplicationRecord
participant :notes_with_associations
attr_spammable :title, spam_title: true
- attr_spammable :content, spam_description: true
+ attr_spammable :description, spam_description: true
attr_encrypted :secret_token,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -269,13 +269,7 @@ class Snippet < ApplicationRecord
def check_for_spam?(user:)
visibility_level_changed?(to: Snippet::PUBLIC) ||
- (public? && (title_changed? || content_changed?))
- end
-
- # snippets are the biggest sources of spam
- override :allow_possible_spam?
- def allow_possible_spam?
- false
+ (public? && (title_changed? || description_changed?))
end
def spammable_entity_type
diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb
index 5ac159d9615..a959ad4d548 100644
--- a/app/models/snippet_repository.rb
+++ b/app/models/snippet_repository.rb
@@ -31,7 +31,7 @@ class SnippetRepository < ApplicationRecord
options[:actions] = transform_file_entries(files)
- capture_git_error { repository.multi_action(user, **options) }
+ capture_git_error { repository.commit_files(user, **options) }
ensure
Gitlab::ExclusiveLease.cancel(lease_key, uuid)
end
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index cc389dbe3f4..4e86036952b 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -25,6 +25,7 @@ class SystemNoteMetadata < ApplicationRecord
tag due_date start_date_or_due_date pinned_embed cherry_pick health_status approved unapproved
status alert_issue_added relate unrelate new_alert_added severity
attention_requested attention_request_removed contact timeline_event
+ issue_type relate_to_child unrelate_from_child relate_to_parent unrelate_from_parent
].freeze
validates :note, presence: true, unless: :importing?
diff --git a/app/models/todo.rb b/app/models/todo.rb
index d165e60e4c3..634fa9e7eda 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -96,10 +96,11 @@ class Todo < ApplicationRecord
def for_group_ids_and_descendants(group_ids)
groups = Group.groups_including_descendants_by(group_ids)
- from_union([
- for_project(Project.for_group(groups)),
- for_group(groups)
- ])
+ from_union(
+ [
+ for_project(Project.for_group(groups)),
+ for_group(groups)
+ ])
end
# Returns `true` if the current user has any todos for the given target with the optional given state.
diff --git a/app/models/user.rb b/app/models/user.rb
index afee2d70844..8825c18ea48 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -92,7 +92,6 @@ class User < ApplicationRecord
include ForcedEmailConfirmation
include RequireEmailVerification
- MINIMUM_INACTIVE_DAYS = 90
MINIMUM_DAYS_CREATED = 7
# Override Devise::Models::Trackable#update_tracked_fields!
@@ -262,6 +261,7 @@ class User < ApplicationRecord
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE }
validates :username, presence: true
+ validate :check_password_weakness, if: :encrypted_password_changed?
validates :namespace, presence: true
validate :namespace_move_dir_allowed, if: :username_changed?
@@ -488,7 +488,7 @@ class User < ApplicationRecord
scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) }
scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) }
scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) }
- scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) }
+ scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', Gitlab::CurrentSettings.deactivate_dormant_users_period.day.ago.to_date) }
scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) }
scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) }
scope :by_ids_or_usernames, -> (ids, usernames) { where(username: usernames).or(where(id: ids)) }
@@ -697,28 +697,29 @@ class User < ApplicationRecord
scope = options[:with_private_emails] ? with_primary_or_secondary_email(query) : with_public_email(query)
scope = scope.or(search_by_name_or_username(query, use_minimum_char_limit: options[:use_minimum_char_limit]))
- order = Gitlab::Pagination::Keyset::Order.build([
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'users_match_priority',
- order_expression: sanitized_order_sql.asc,
- add_to_projections: true,
- distinct: false
- ),
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'users_name',
- order_expression: arel_table[:name].asc,
- add_to_projections: true,
- nullable: :not_nullable,
- distinct: false
- ),
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'users_id',
- order_expression: arel_table[:id].asc,
- add_to_projections: true,
- nullable: :not_nullable,
- distinct: true
- )
- ])
+ order = Gitlab::Pagination::Keyset::Order.build(
+ [
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'users_match_priority',
+ order_expression: sanitized_order_sql.asc,
+ add_to_projections: true,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'users_name',
+ order_expression: arel_table[:name].asc,
+ add_to_projections: true,
+ nullable: :not_nullable,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'users_id',
+ order_expression: arel_table[:id].asc,
+ add_to_projections: true,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ ])
scope.reorder(order)
end
@@ -1358,10 +1359,11 @@ class User < ApplicationRecord
end
def accessible_deploy_keys
- DeployKey.from_union([
- DeployKey.where(id: project_deploy_keys.select(:deploy_key_id)),
- DeployKey.are_public
- ])
+ DeployKey.from_union(
+ [
+ DeployKey.where(id: project_deploy_keys.select(:deploy_key_id)),
+ DeployKey.are_public
+ ])
end
def created_by
@@ -1662,10 +1664,11 @@ class User < ApplicationRecord
strong_memoize(:forkable_namespaces) do
personal_namespace = Namespace.where(id: namespace_id)
- Namespace.from_union([
- manageable_groups(include_groups_with_developer_maintainer_access: true),
- personal_namespace
- ])
+ Namespace.from_union(
+ [
+ manageable_groups(include_groups_with_developer_maintainer_access: true),
+ personal_namespace
+ ])
end
end
@@ -2072,6 +2075,7 @@ class User < ApplicationRecord
callout_dismissed?(callout, ignore_dismissal_earlier_than)
end
+ # Deprecated: do not use. See: https://gitlab.com/gitlab-org/gitlab/-/issues/371017
def dismissed_callout_for_namespace?(feature_name:, namespace:, ignore_dismissal_earlier_than: nil)
source_feature_name = "#{feature_name}_#{namespace.id}"
callout = namespace_callouts_by_feature_name[source_feature_name]
@@ -2151,10 +2155,6 @@ class User < ApplicationRecord
end
end
- def mr_attention_requests_enabled?
- Feature.enabled?(:mr_attention_requests, self)
- end
-
def account_age_in_days
(Date.current - created_at.to_date).to_i
end
@@ -2247,10 +2247,11 @@ class User < ApplicationRecord
end
def authorized_groups_without_shared_membership
- Group.from_union([
- groups.select(*Namespace.cached_column_list),
- authorized_projects.joins(:namespace).select(*Namespace.cached_column_list)
- ])
+ Group.from_union(
+ [
+ groups.select(*Namespace.cached_column_list),
+ authorized_projects.joins(:namespace).select(*Namespace.cached_column_list)
+ ])
end
def authorized_groups_with_shared_membership
@@ -2260,10 +2261,10 @@ class User < ApplicationRecord
Group
.with(cte.to_arel)
.from_union([
- Group.from(cte_alias),
- Group.joins(:shared_with_group_links)
- .where(group_group_links: { shared_with_group_id: Group.from(cte_alias) })
- ])
+ Group.from(cte_alias),
+ Group.joins(:shared_with_group_links)
+ .where(group_group_links: { shared_with_group_id: Group.from(cte_alias) })
+ ])
end
def default_private_profile_to_false
@@ -2314,6 +2315,14 @@ class User < ApplicationRecord
errors.add(:username, _('ending with a reserved file extension is not allowed.'))
end
+ def check_password_weakness
+ if Feature.enabled?(:block_weak_passwords) &&
+ password.present? &&
+ Security::WeakPasswords.weak_for_user?(password, self)
+ errors.add(:password, _('must not contain commonly used combinations of words and letters'))
+ end
+ end
+
def groups_with_developer_maintainer_project_access
project_creation_levels = [::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS]
@@ -2325,7 +2334,7 @@ class User < ApplicationRecord
end
def no_recent_activity?
- last_active_at.to_i <= MINIMUM_INACTIVE_DAYS.days.ago.to_i
+ last_active_at.to_i <= Gitlab::CurrentSettings.deactivate_dormant_users_period.days.ago.to_i
end
def update_highest_role?
diff --git a/app/models/user_status.rb b/app/models/user_status.rb
index dee976a4497..0c66f465356 100644
--- a/app/models/user_status.rb
+++ b/app/models/user_status.rb
@@ -29,6 +29,10 @@ class UserStatus < ApplicationRecord
cache_markdown_field :message, pipeline: :emoji
+ def clear_status_after
+ clear_status_at
+ end
+
def clear_status_after=(value)
self.clear_status_at = CLEAR_STATUS_QUICK_OPTIONS[value]&.from_now
end
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 7b5c7fef7ba..03841ee48fa 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -43,12 +43,11 @@ module Users
verification_reminder: 40, # EE-only
ci_deprecation_warning_for_types_keyword: 41,
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,
+ storage_enforcement_banner_first_enforcement_threshold: 43, # EE-only
+ storage_enforcement_banner_second_enforcement_threshold: 44, # EE-only
+ storage_enforcement_banner_third_enforcement_threshold: 45, # EE-only
+ storage_enforcement_banner_fourth_enforcement_threshold: 46, # EE-only
+ # 47 and 48 were removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95446
# 49 was removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91533
# because the banner was no longer relevant.
# Records will be migrated with https://gitlab.com/gitlab-org/gitlab/-/issues/367293
@@ -61,7 +60,8 @@ module Users
namespace_storage_limit_banner_warning_threshold: 56, # EE-only
namespace_storage_limit_banner_alert_threshold: 57, # EE-only
namespace_storage_limit_banner_error_threshold: 58, # EE-only
- project_quality_summary_feedback: 59 # EE-only
+ project_quality_summary_feedback: 59, # EE-only
+ merge_request_settings_moved_callout: 60
}
validates :feature_name,
diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb
index 998a5deb0fd..272f31aa9ce 100644
--- a/app/models/users/credit_card_validation.rb
+++ b/app/models/users/credit_card_validation.rb
@@ -21,5 +21,11 @@ module Users
network: network
).order(credit_card_validated_at: :desc).includes(:user)
end
+
+ def similar_holder_names_count
+ return 0 unless holder_name
+
+ self.class.where('lower(holder_name) = lower(:value)', value: holder_name).count
+ end
end
end
diff --git a/app/models/users/ghost_user_migration.rb b/app/models/users/ghost_user_migration.rb
new file mode 100644
index 00000000000..1d93498e88b
--- /dev/null
+++ b/app/models/users/ghost_user_migration.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Users
+ class GhostUserMigration < ApplicationRecord
+ self.table_name = 'ghost_user_migrations'
+
+ belongs_to :user
+ belongs_to :initiator_user, class_name: 'User'
+
+ validates :user_id, presence: true
+ end
+end
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index 70498ae83e0..3e3e424e9c9 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: 3,
- storage_enforcement_banner_second_enforcement_threshold: 4,
- storage_enforcement_banner_third_enforcement_threshold: 5,
- storage_enforcement_banner_fourth_enforcement_threshold: 6,
+ storage_enforcement_banner_first_enforcement_threshold: 3, # EE-only
+ storage_enforcement_banner_second_enforcement_threshold: 4, # EE-only
+ storage_enforcement_banner_third_enforcement_threshold: 5, # EE-only
+ storage_enforcement_banner_fourth_enforcement_threshold: 6, # EE-only
preview_user_over_limit_free_plan_alert: 7, # EE-only
user_reached_limit_free_plan_alert: 8, # EE-only
free_group_limited_alert: 9, # EE-only
diff --git a/app/models/users/namespace_callout.rb b/app/models/users/namespace_callout.rb
index a20a196a4ef..4e655a96b57 100644
--- a/app/models/users/namespace_callout.rb
+++ b/app/models/users/namespace_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: 3,
- storage_enforcement_banner_second_enforcement_threshold: 4,
- storage_enforcement_banner_third_enforcement_threshold: 5,
- storage_enforcement_banner_fourth_enforcement_threshold: 6,
+ storage_enforcement_banner_first_enforcement_threshold: 3, # EE-only
+ storage_enforcement_banner_second_enforcement_threshold: 4, # EE-only
+ storage_enforcement_banner_third_enforcement_threshold: 5, # EE-only
+ storage_enforcement_banner_fourth_enforcement_threshold: 6, # EE-only
preview_user_over_limit_free_plan_alert: 7, # EE-only
user_reached_limit_free_plan_alert: 8, # EE-only
web_hook_disabled: 9
diff --git a/app/models/users/project_callout.rb b/app/models/users/project_callout.rb
index ddc5f8fb4de..98dacbe394a 100644
--- a/app/models/users/project_callout.rb
+++ b/app/models/users/project_callout.rb
@@ -9,7 +9,9 @@ module Users
belongs_to :project
enum feature_name: {
- awaiting_members_banner: 1 # EE-only
+ awaiting_members_banner: 1, # EE-only
+ web_hook_disabled: 2,
+ ultimate_feature_removal_banner: 3
}
validates :project, presence: true
diff --git a/app/models/users_star_project.rb b/app/models/users_star_project.rb
index 1549c099a64..9a514b82506 100644
--- a/app/models/users_star_project.rb
+++ b/app/models/users_star_project.rb
@@ -3,7 +3,7 @@
class UsersStarProject < ApplicationRecord
include Sortable
- belongs_to :project, counter_cache: :star_count, touch: true
+ belongs_to :project, counter_cache: :star_count
belongs_to :user
validates :user, presence: true
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index d28a73b644f..fac79a8194a 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -103,6 +103,17 @@ class Wiki
def find_by_id(container_id)
container_class.find_by_id(container_id)&.wiki
end
+
+ def sluggified_full_path(title, extension)
+ sluggified_title(title) + '.' + extension
+ end
+
+ def sluggified_title(title)
+ title = Gitlab::EncodingHelper.encode_utf8_no_detect(title)
+ title = File.expand_path(title, '/')
+ title = Pathname.new(title).relative_path_from('/').to_s
+ title.tr(' ', '-')
+ end
end
def initialize(container, user = nil)
@@ -206,10 +217,11 @@ class Wiki
#
# Returns an initialized WikiPage instance or 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, load_content: load_content)
- WikiPage.new(self, page)
+ if find_page_with_repository_rpcs?
+ create_wiki_repository unless repository_exists?
+ find_page_with_repository_rpcs(title, version, load_content: load_content)
+ else
+ find_page_with_legacy_wiki_service(title, version, load_content: load_content)
end
end
@@ -419,19 +431,83 @@ class Wiki
end
def sluggified_full_path(title, extension)
- sluggified_title(title) + '.' + extension
+ self.class.sluggified_full_path(title, extension)
end
def sluggified_title(title)
- utf8_encoded_title = Gitlab::EncodingHelper.encode_utf8_no_detect(title)
+ self.class.sluggified_title(title)
+ end
- sanitized_title(utf8_encoded_title).tr(' ', '-')
+ def canonicalize_filename(filename)
+ Gitlab::Git::Wiki::GollumSlug.canonicalize_filename(filename)
end
- def sanitized_title(title)
- clean_absolute_path = File.expand_path(title, '/')
+ def find_page_with_legacy_wiki_service(title, version, load_content: false)
+ page_title, page_dir = page_title_and_dir(title)
+
+ if page = wiki.page(title: page_title, version: version, dir: page_dir, load_content: load_content)
+ WikiPage.new(self, page)
+ end
+ end
+
+ def find_matched_file(title, version)
+ escaped_path = RE2::Regexp.escape(sluggified_title(title))
+ # We could not use ALLOWED_EXTENSIONS_REGEX constant or similar regexp with
+ # Regexp.union. The result combination complicated modifiers:
+ # /(?i-mx:md|mkdn?|mdown|markdown)|(?i-mx:rdoc).../
+ # Regexp used by Gitaly is Go's Regexp package. It does not support those
+ # features. So, we have to compose another more-friendly regexp to pass to
+ # Gitaly side.
+ extension_regexp = Wiki::MARKUPS.map { |_, format| format[:extension_regex].source }.join("|")
+ path_regexp = Gitlab::EncodingHelper.encode_utf8_no_detect("(?i)^#{escaped_path}\\.(#{extension_regexp})$")
+
+ matched_files = repository.search_files_by_regexp(path_regexp, version)
+ return if matched_files.blank?
+
+ Gitlab::EncodingHelper.encode_utf8_no_detect(matched_files.first)
+ end
+
+ def find_page_format(path)
+ ext = File.extname(path).downcase[1..]
+ MARKUPS.find { |_, markup| markup[:extension_regex].match?(ext) }&.first
+ end
+
+ def check_page_historical(path, commit)
+ repository.last_commit_for_path('HEAD', path).id != commit.id
+ end
+
+ def find_page_with_repository_rpcs(title, version, load_content: true)
+ version = version.presence || 'HEAD'
+ path = find_matched_file(title, version)
+ return if path.blank?
+
+ blob_options = load_content ? {} : { limit: 0 }
+ blob = repository.blob_at(version, path, **blob_options)
+ commit = repository.commit(blob.commit_id)
+ format = find_page_format(path)
+
+ page = Gitlab::Git::WikiPage.new(
+ url_path: sluggified_title(path.sub(/\.[^.]+\z/, "")),
+ title: canonicalize_filename(path),
+ format: format,
+ path: sluggified_title(path),
+ raw_data: blob.data,
+ name: canonicalize_filename(path),
+ historical: version == 'HEAD' ? false : check_page_historical(path, commit),
+ version: Gitlab::Git::WikiPageVersion.new(commit, format)
+ )
+ WikiPage.new(self, page)
+ end
+
+ def find_page_with_repository_rpcs?
+ group =
+ if container.is_a?(::Group)
+ container
+ else
+ container.group
+ end
- Pathname.new(clean_absolute_path).relative_path_from('/').to_s
+ Feature.enabled?(:wiki_find_page_with_normal_repository_rpcs, group, type: :development)
end
end
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 451359c1f85..05e45fa5b29 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -37,11 +37,11 @@ class WorkItem < Issue
override :parent_link_confidentiality
def parent_link_confidentiality
if confidential? && work_item_children.public_only.exists?
- errors.add(:confidential, _('confidential parent can not be used if there are non-confidential children.'))
+ errors.add(:base, _('A confidential work item cannot have a parent that already has non-confidential children.'))
end
if !confidential? && work_item_parent&.confidential?
- errors.add(:confidential, _('associated parent is confidential and can not have non-confidential children.'))
+ errors.add(:base, _('A non-confidential work item cannot have a confidential parent.'))
end
end
diff --git a/app/models/work_items/widgets/description.rb b/app/models/work_items/widgets/description.rb
index 1e84d172bef..ec3b7957c79 100644
--- a/app/models/work_items/widgets/description.rb
+++ b/app/models/work_items/widgets/description.rb
@@ -3,7 +3,13 @@
module WorkItems
module Widgets
class Description < Base
- delegate :description, to: :work_item
+ delegate :description, :edited?, :last_edited_at, to: :work_item
+
+ def last_edited_by
+ return unless work_item.edited?
+
+ work_item.last_edited_by
+ end
end
end
end
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index f377ff85b5e..b657b569e3e 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -2,6 +2,8 @@
module Ci
class BuildPolicy < CommitStatusPolicy
+ delegate { @subject.project }
+
condition(:protected_ref) do
access = ::Gitlab::UserAccess.new(@user, container: @subject.project)
@@ -25,6 +27,10 @@ module Ci
false
end
+ condition(:prevent_rollback) do
+ @subject.prevent_rollback_deployment?
+ end
+
condition(:owner_of_job) do
@subject.triggered_by?(@user)
end
@@ -71,7 +77,7 @@ module Ci
# Authorizing the user to access to protected entities.
# There is a "jailbreak" mode to exceptionally bypass the authorization,
# however, you should NEVER allow it, rather suspect it's a wrong feature/product design.
- rule { ~can?(:jailbreak) & (archived | protected_ref | protected_environment) }.policy do
+ rule { ~can?(:jailbreak) & (archived | protected_ref | protected_environment | prevent_rollback) }.policy do
prevent :update_build
prevent :update_commit_status
prevent :erase_build
diff --git a/app/policies/ci/job_artifact_policy.rb b/app/policies/ci/job_artifact_policy.rb
new file mode 100644
index 00000000000..e25c7311565
--- /dev/null
+++ b/app/policies/ci/job_artifact_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class JobArtifactPolicy < BasePolicy
+ delegate { @subject.job.project }
+ end
+end
diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb
index 8a99f4d1a3e..a52dac446ea 100644
--- a/app/policies/ci/runner_policy.rb
+++ b/app/policies/ci/runner_policy.rb
@@ -9,19 +9,65 @@ module Ci
@user.owns_runner?(@subject)
end
- condition(:belongs_to_multiple_projects) do
+ with_options scope: :subject, score: 0
+ condition(:is_instance_runner) do
+ @subject.instance_type?
+ end
+
+ with_options scope: :subject, score: 0
+ condition(:is_group_runner) do
+ @subject.group_type?
+ end
+
+ with_options scope: :user, score: 5
+ condition(:any_developer_groups_inheriting_shared_runners) do
+ @user.developer_groups.with_shared_runners_enabled.any?
+ end
+
+ with_options scope: :user, score: 5
+ condition(:any_developer_projects_inheriting_shared_runners) do
+ @user.authorized_projects(Gitlab::Access::DEVELOPER).with_shared_runners_enabled.any?
+ end
+
+ with_options score: 10
+ condition(:any_associated_projects_in_group_runner_inheriting_group_runners) do
+ # Check if any projects where user is a developer are inheriting group runners
+ @subject.groups&.any? do |group|
+ group.all_projects
+ .with_group_runners_enabled
+ .visible_to_user_and_access_level(@user, Gitlab::Access::DEVELOPER)
+ .exists?
+ end
+ end
+
+ condition(:belongs_to_multiple_projects, scope: :subject) do
@subject.belongs_to_more_than_one_project?
end
rule { anonymous }.prevent_all
- rule { admin }.policy do
+ rule { admin | owned_runner }.policy do
enable :read_builds
end
rule { admin | owned_runner }.policy do
- enable :assign_runner
enable :read_runner
+ end
+
+ rule { is_instance_runner & any_developer_groups_inheriting_shared_runners }.policy do
+ enable :read_runner
+ end
+
+ rule { is_instance_runner & any_developer_projects_inheriting_shared_runners }.policy do
+ enable :read_runner
+ end
+
+ rule { is_group_runner & any_associated_projects_in_group_runner_inheriting_group_runners }.policy do
+ enable :read_runner
+ end
+
+ rule { admin | owned_runner }.policy do
+ enable :assign_runner
enable :update_runner
enable :delete_runner
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 44393539327..96da0518dc0 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -59,6 +59,10 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
access_level(for_any_session: true) >= GroupMember::GUEST || valid_dependency_proxy_deploy_token
end
+ condition(:observability_enabled) do
+ Feature.enabled?(:observability_group_tab, @subject)
+ end
+
desc "Deploy token with read_package_registry scope"
condition(:read_package_registry_deploy_token) do
@user.is_a?(DeployToken) && @user.groups.include?(@subject) && @user.read_package_registry
@@ -82,10 +86,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group')
end
- condition(:change_prevent_sharing_groups_outside_hierarchy_available) do
- change_prevent_sharing_groups_outside_hierarchy_available?
- end
-
rule { can?(:read_group) & design_management_enabled }.policy do
enable :read_design_activity
end
@@ -196,6 +196,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :set_note_created_at
enable :set_emails_disabled
+ enable :change_prevent_sharing_groups_outside_hierarchy
+ enable :set_show_diff_preview_in_email
enable :change_new_user_signups_cap
enable :update_default_branch_protection
enable :create_deploy_token
@@ -204,10 +206,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :owner_access
end
- rule { owner & change_prevent_sharing_groups_outside_hierarchy_available }.policy do
- enable :change_prevent_sharing_groups_outside_hierarchy
- end
-
rule { can?(:read_nested_project_resources) }.policy do
enable :read_group_activity
enable :read_group_issues
@@ -299,6 +297,10 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :destroy_resource_access_tokens
end
+ rule { can?(:developer_access) & observability_enabled }.policy do
+ enable :read_observability
+ end
+
def access_level(for_any_session: false)
return GroupMember::NO_ACCESS if @user.nil?
return GroupMember::NO_ACCESS unless user_is_user?
@@ -335,10 +337,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
def valid_dependency_proxy_deploy_token
@user.is_a?(DeployToken) && @user&.valid_for_dependency_proxy? && @user&.has_access_to_group?(@subject)
end
-
- def change_prevent_sharing_groups_outside_hierarchy_available?
- true
- end
end
GroupPolicy.prepend_mod_with('GroupPolicy')
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index 3c5e1020c8a..e5913bab726 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -5,6 +5,7 @@ class IssuablePolicy < BasePolicy
condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? }
condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) }
+ condition(:can_read_issuable) { can?(:"read_#{@subject.to_ability_name}") }
desc "User is the assignee or author"
condition(:assignee_or_author) do
@@ -48,6 +49,10 @@ class IssuablePolicy < BasePolicy
rule { can?(:reporter_access) }.policy do
enable :create_timelog
end
+
+ rule { can_read_issuable }.policy do
+ enable :read_issuable
+ end
end
IssuablePolicy.prepend_mod_with('IssuablePolicy')
diff --git a/app/policies/packages/package_policy.rb b/app/policies/packages/package_policy.rb
index 8eef280c640..829d62a6430 100644
--- a/app/policies/packages/package_policy.rb
+++ b/app/policies/packages/package_policy.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Packages
class PackagePolicy < BasePolicy
- delegate { @subject.project }
+ delegate { @subject.project&.packages_policy_subject }
end
end
diff --git a/app/policies/packages/policies/group_policy.rb b/app/policies/packages/policies/group_policy.rb
new file mode 100644
index 00000000000..32dbcb1b65b
--- /dev/null
+++ b/app/policies/packages/policies/group_policy.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Packages
+ module Policies
+ class GroupPolicy < BasePolicy
+ delegate(:group) { @subject.group }
+
+ overrides(:read_package)
+
+ rule { group.public_group }.policy do
+ enable :read_package
+ end
+
+ rule { group.reporter }.policy do
+ enable :read_package
+ end
+
+ rule { group.read_package_registry_deploy_token }.policy do
+ enable :read_package
+ end
+
+ rule { group.write_package_registry_deploy_token }.policy do
+ enable :read_package
+ end
+ end
+ end
+end
diff --git a/app/policies/packages/policies/project_policy.rb b/app/policies/packages/policies/project_policy.rb
new file mode 100644
index 00000000000..c754d24349a
--- /dev/null
+++ b/app/policies/packages/policies/project_policy.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Packages
+ module Policies
+ class ProjectPolicy < BasePolicy
+ delegate(:project) { @subject.project }
+
+ overrides(:read_package)
+
+ condition(:package_registry_access_level_feature_flag_enabled, scope: :subject) do
+ ::Feature.enabled?(:package_registry_access_level, @subject)
+ end
+
+ condition(:packages_enabled_for_everyone, scope: :subject) do
+ @subject.package_registry_access_level == ProjectFeature::PUBLIC
+ end
+
+ # This rule can be removed if the `package_registry_access_level` feature flag is removed.
+ # Reason: If the feature flag is globally enabled, this rule will never be executed.
+ rule { anonymous & ~project.public_project & ~package_registry_access_level_feature_flag_enabled }.prevent_all
+
+ # This rule can be removed if the `package_registry_access_level` feature flag is removed.
+ # Reason: If the feature flag is globally enabled, this rule will never be executed.
+ rule do
+ ~project.public_project & ~project.internal_access &
+ ~project.project_allowed_for_job_token & ~package_registry_access_level_feature_flag_enabled
+ end.prevent_all
+
+ rule { project.packages_disabled }.policy do
+ prevent(:read_package)
+ end
+
+ rule { can?(:reporter_access) }.policy do
+ enable :read_package
+ end
+
+ rule { can?(:public_access) }.policy do
+ enable :read_package
+ end
+
+ rule { project.read_package_registry_deploy_token }.policy do
+ enable :read_package
+ end
+
+ rule { project.write_package_registry_deploy_token }.policy do
+ enable :read_package
+ end
+
+ rule { package_registry_access_level_feature_flag_enabled & packages_enabled_for_everyone }.policy do
+ enable :read_package
+ end
+ end
+ end
+end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index f4f7275a78a..fb162d03955 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -208,6 +208,7 @@ class ProjectPolicy < BasePolicy
metrics_dashboard
analytics
operations
+ monitor
security_and_compliance
environments
feature_flags
@@ -267,6 +268,7 @@ class ProjectPolicy < BasePolicy
enable :set_note_created_at
enable :set_emails_disabled
enable :set_show_default_award_emojis
+ enable :set_show_diff_preview_in_email
enable :set_warn_about_potentially_unwanted_characters
enable :register_project_runners
@@ -401,6 +403,12 @@ class ProjectPolicy < BasePolicy
prevent(*create_read_update_admin_destroy(:release))
end
+ rule { split_operations_visibility_permissions & monitor_disabled }.policy do
+ prevent(:metrics_dashboard)
+ prevent(*create_read_update_admin_destroy(:sentry_issue))
+ prevent(*create_read_update_admin_destroy(:alert_management_alert))
+ end
+
rule { can?(:metrics_dashboard) }.policy do
enable :read_prometheus
enable :read_deployment
diff --git a/app/policies/protected_branch_access_policy.rb b/app/policies/protected_branch_access_policy.rb
new file mode 100644
index 00000000000..4f33af89d2a
--- /dev/null
+++ b/app/policies/protected_branch_access_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ProtectedBranchAccessPolicy < BasePolicy
+ delegate { @subject.protected_branch }
+end
diff --git a/app/policies/protected_branch_policy.rb b/app/policies/protected_branch_policy.rb
index 8ad06653e5c..2be96ea7f24 100644
--- a/app/policies/protected_branch_policy.rb
+++ b/app/policies/protected_branch_policy.rb
@@ -4,6 +4,7 @@ class ProtectedBranchPolicy < BasePolicy
delegate { @subject.project }
rule { can?(:admin_project) }.policy do
+ enable :read_protected_branch
enable :create_protected_branch
enable :update_protected_branch
enable :destroy_protected_branch
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 887980430f4..32a7d205f46 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -49,7 +49,7 @@ module Ci
{
merge_train: s_('Pipeline|Merge train pipeline'),
merged_result: s_('Pipeline|Merged result pipeline'),
- detached: s_('Pipeline|Detached merge request pipeline')
+ detached: s_('Pipeline|Merge request pipeline')
}.freeze
end
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 815a4da25ab..059d6d06be2 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -26,6 +26,7 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
downstream_pipeline_creation_failed: 'The downstream pipeline could not be created',
secrets_provider_not_found: 'The secrets provider can not be found',
reached_max_descendant_pipelines_depth: 'You reached the maximum depth of child pipelines',
+ reached_max_pipeline_hierarchy_size: 'The downstream pipeline tree is too large',
project_deleted: 'The job belongs to a deleted project',
user_blocked: 'The user who created this job is blocked',
ci_quota_exceeded: 'No more CI minutes available',
@@ -34,11 +35,13 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
builds_disabled: 'The CI/CD is disabled for this project',
environment_creation_failure: 'This job could not be executed because it would create an environment with an invalid parameter.',
deployment_rejected: 'This deployment job was rejected.',
- ip_restriction_failure: "This job could not be executed because group IP address restrictions are enabled, and the runner's IP address is not in the allowed range."
+ ip_restriction_failure: "This job could not be executed because group IP address restrictions are enabled, and the runner's IP address is not in the allowed range.",
+ failed_outdated_deployment_job: 'The deployment job is older than the latest deployment, and therefore failed.'
}.freeze
TROUBLESHOOTING_DOC = {
- environment_creation_failure: { path: 'ci/environments/index', anchor: 'a-deployment-job-failed-with-this-job-could-not-be-executed-because-it-would-create-an-environment-with-an-invalid-parameter-error' }
+ environment_creation_failure: { path: 'ci/environments/index', anchor: 'a-deployment-job-failed-with-this-job-could-not-be-executed-because-it-would-create-an-environment-with-an-invalid-parameter-error' },
+ failed_outdated_deployment_job: { path: 'ci/environments/deployment_safety', anchor: 'skip-outdated-deployment-jobs' }
}.freeze
private_constant :CALLOUT_FAILURE_MESSAGES
diff --git a/app/presenters/deployments/deployment_presenter.rb b/app/presenters/deployments/deployment_presenter.rb
new file mode 100644
index 00000000000..5ef6fcff974
--- /dev/null
+++ b/app/presenters/deployments/deployment_presenter.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Deployments
+ class DeploymentPresenter < Gitlab::View::Presenter::Delegated
+ presents ::Deployment, as: :deployment
+
+ delegator_override :tags
+ def tags
+ super.map do |tag|
+ {
+ name: tag,
+ path: "tags/#{tag}"
+ }
+ end
+ end
+ end
+end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 209f016dc6b..0be13197343 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -317,6 +317,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def autodevops_anchor_data(show_auto_devops_callout: false)
+ return unless project.feature_available?(:builds, current_user)
+
if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout
if auto_devops_enabled?
AnchorData.new(false,
diff --git a/app/serializers/access_token_entity_base.rb b/app/serializers/access_token_entity_base.rb
new file mode 100644
index 00000000000..db22dbf1302
--- /dev/null
+++ b/app/serializers/access_token_entity_base.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# rubocop: disable Gitlab/NamespacedClass
+class AccessTokenEntityBase < API::Entities::PersonalAccessToken
+ expose :expired?, as: :expired
+ expose :expires_soon?, as: :expires_soon
+end
+# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 6363d6276a7..22839ba3099 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -96,8 +96,7 @@ class EnvironmentSerializer < BaseSerializer
scheduled_actions: [:metadata],
latest_successful_builds: []
},
- project: project_associations,
- deployment: []
+ project: project_associations
}
}
end
diff --git a/app/serializers/group_access_token_entity.rb b/app/serializers/group_access_token_entity.rb
index e832eef1188..ab1fbb8ab46 100644
--- a/app/serializers/group_access_token_entity.rb
+++ b/app/serializers/group_access_token_entity.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# rubocop: disable Gitlab/NamespacedClass
-class GroupAccessTokenEntity < API::Entities::PersonalAccessToken
+class GroupAccessTokenEntity < AccessTokenEntityBase
include Gitlab::Routing
expose :revoke_path do |token, options|
@@ -14,13 +14,13 @@ class GroupAccessTokenEntity < API::Entities::PersonalAccessToken
group_id: group.path)
end
- expose :access_level do |token, options|
+ expose :role do |token, options|
group = options.fetch(:group)
next unless group
next unless token.user
- group.member(token.user)&.access_level
+ group.member(token.user)&.human_access
end
end
# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/impersonation_access_token_entity.rb b/app/serializers/impersonation_access_token_entity.rb
new file mode 100644
index 00000000000..b4ed62a890d
--- /dev/null
+++ b/app/serializers/impersonation_access_token_entity.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# rubocop: disable Gitlab/NamespacedClass
+class ImpersonationAccessTokenEntity < AccessTokenEntityBase
+ include Gitlab::Routing
+
+ expose :revoke_path do |token, _options|
+ revoke_admin_user_impersonation_token_path(token.user, token)
+ end
+end
+# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/impersonation_access_token_serializer.rb b/app/serializers/impersonation_access_token_serializer.rb
new file mode 100644
index 00000000000..d3ea5ceb305
--- /dev/null
+++ b/app/serializers/impersonation_access_token_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# rubocop: disable Gitlab/NamespacedClass
+class ImpersonationAccessTokenSerializer < BaseSerializer
+ entity ImpersonationAccessTokenEntity
+end
+# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/import/provider_repo_serializer.rb b/app/serializers/import/provider_repo_serializer.rb
index edd1a260146..d5d29603989 100644
--- a/app/serializers/import/provider_repo_serializer.rb
+++ b/app/serializers/import/provider_repo_serializer.rb
@@ -23,3 +23,5 @@ class Import::ProviderRepoSerializer < BaseSerializer
super(repo, opts, entity)
end
end
+
+Import::ProviderRepoSerializer.prepend_mod
diff --git a/app/serializers/member_user_entity.rb b/app/serializers/member_user_entity.rb
index 6a01c5bb297..73cb9a4a798 100644
--- a/app/serializers/member_user_entity.rb
+++ b/app/serializers/member_user_entity.rb
@@ -16,6 +16,10 @@ class MemberUserEntity < UserEntity
user.blocked?
end
+ expose :is_bot do |user|
+ user.bot?
+ end
+
expose :two_factor_enabled, if: -> (user) { current_user_can_manage_members? || current_user?(user) } do |user|
user.two_factor_enabled?
end
diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb
index f8c8e3538da..29bd26c3a15 100644
--- a/app/serializers/merge_request_noteable_entity.rb
+++ b/app/serializers/merge_request_noteable_entity.rb
@@ -10,6 +10,15 @@ class MergeRequestNoteableEntity < IssuableEntity
expose :state
expose :source_branch
expose :target_branch
+
+ expose :source_branch_path, if: -> (merge_request) { merge_request.source_project } do |merge_request|
+ project_tree_path(merge_request.source_project, merge_request.source_branch)
+ end
+
+ expose :target_branch_path, if: -> (merge_request) { merge_request.source_project } do |merge_request|
+ project_tree_path(merge_request.source_project, merge_request.target_branch)
+ end
+
expose :diff_head_sha
expose :create_note_path do |merge_request|
@@ -40,6 +49,10 @@ class MergeRequestNoteableEntity < IssuableEntity
expose :can_update do |merge_request|
can?(current_user, :update_merge_request, merge_request)
end
+
+ expose :can_approve do |merge_request|
+ merge_request.can_be_approved_by?(current_user)
+ end
end
expose :locked_discussion_docs_path, if: -> (merge_request) { merge_request.discussion_locked? } do |merge_request|
@@ -65,3 +78,5 @@ class MergeRequestNoteableEntity < IssuableEntity
@presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user) # rubocop: disable CodeReuse/Presenter
end
end
+
+MergeRequestNoteableEntity.prepend_mod_with('MergeRequestNoteableEntity')
diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb
index 2e875af6531..36825d14062 100644
--- a/app/serializers/merge_request_user_entity.rb
+++ b/app/serializers/merge_request_user_entity.rb
@@ -17,7 +17,7 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic
end
expose :reviewed, if: satisfies(:present?, :allows_reviewers?) do |user, options|
- find_reviewer_or_assignee(user, options)&.reviewed?
+ options[:merge_request].find_reviewer(user)&.reviewed?
end
expose :approved, if: satisfies(:present?) do |user, options|
@@ -25,16 +25,6 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic
# makes one query per merge request, whereas #approved_by? makes one per user
options[:merge_request].approvals.any? { |app| app.user_id == user.id }
end
-
- private
-
- def find_reviewer_or_assignee(user, options)
- if options[:type] == :reviewers
- options[:merge_request].find_reviewer(user)
- else
- options[:merge_request].find_assignee(user)
- end
- end
end
MergeRequestUserEntity.prepend_mod_with('MergeRequestUserEntity')
diff --git a/app/serializers/personal_access_token_entity.rb b/app/serializers/personal_access_token_entity.rb
index acd06fecd12..49dcdf12a6f 100644
--- a/app/serializers/personal_access_token_entity.rb
+++ b/app/serializers/personal_access_token_entity.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# rubocop: disable Gitlab/NamespacedClass
-class PersonalAccessTokenEntity < API::Entities::PersonalAccessToken
+class PersonalAccessTokenEntity < AccessTokenEntityBase
include Gitlab::Routing
expose :revoke_path do |token, options|
diff --git a/app/serializers/project_access_token_entity.rb b/app/serializers/project_access_token_entity.rb
index b317057c952..52bb7b05d4e 100644
--- a/app/serializers/project_access_token_entity.rb
+++ b/app/serializers/project_access_token_entity.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# rubocop: disable Gitlab/NamespacedClass
-class ProjectAccessTokenEntity < API::Entities::PersonalAccessToken
+class ProjectAccessTokenEntity < AccessTokenEntityBase
include Gitlab::Routing
expose :revoke_path do |token, options|
@@ -15,13 +15,13 @@ class ProjectAccessTokenEntity < API::Entities::PersonalAccessToken
project_id: project.path)
end
- expose :access_level do |token, options|
+ expose :role do |token, options|
project = options.fetch(:project)
next unless project
next unless token.user
- project.member(token.user)&.access_level
+ project.member(token.user)&.human_access
end
end
# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/request_aware_entity.rb b/app/serializers/request_aware_entity.rb
index 1524c1291d8..04caba43cf4 100644
--- a/app/serializers/request_aware_entity.rb
+++ b/app/serializers/request_aware_entity.rb
@@ -10,6 +10,6 @@ module RequestAwareEntity
end
def request
- options.fetch(:request)
+ options.fetch(:request, nil)
end
end
diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb
index 1b377a3d367..e0594247975 100644
--- a/app/services/alert_management/process_prometheus_alert_service.rb
+++ b/app/services/alert_management/process_prometheus_alert_service.rb
@@ -36,10 +36,5 @@ module AlertManagement
)
end
end
-
- override :resolving_alert?
- def resolving_alert?
- incoming_payload.resolved?
- end
end
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index e806bef46fe..509c2d4d544 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -83,6 +83,7 @@ module Auth
token.audience = params[:service]
token.subject = current_user.try(:username)
token.expire_time = self.class.token_expire_at
+ token[:auth_type] = params[:auth_type]
token[:access] = accesses.compact
end
end
diff --git a/app/services/authorized_project_update/find_records_due_for_refresh_service.rb b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb
index 3a2251f15cc..dd696da0447 100644
--- a/app/services/authorized_project_update/find_records_due_for_refresh_service.rb
+++ b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb
@@ -28,31 +28,33 @@ module AuthorizedProjectUpdate
current.except!(*projects_with_duplicates)
remove |= current.each_with_object([]) do |(project_id, row), array|
+ next if fresh[project_id] && fresh[project_id] == row.access_level
+
# rows not in the new list or with a different access level should be
# removed.
- if !fresh[project_id] || fresh[project_id] != row.access_level
- if incorrect_auth_found_callback
- incorrect_auth_found_callback.call(project_id, row.access_level)
- end
- array << row.project_id
+ if incorrect_auth_found_callback
+ incorrect_auth_found_callback.call(project_id, row.access_level)
end
+
+ array << row.project_id
end
add = fresh.each_with_object([]) do |(project_id, level), array|
+ next if current[project_id] && current[project_id].access_level == level
+
# rows not in the old list or with a different access level should be
# added.
- if !current[project_id] || current[project_id].access_level != level
- if missing_auth_found_callback
- missing_auth_found_callback.call(project_id, level)
- end
-
- array << {
- user_id: user.id,
- project_id: project_id,
- access_level: level
- }
+
+ if missing_auth_found_callback
+ missing_auth_found_callback.call(project_id, level)
end
+
+ array << {
+ user_id: user.id,
+ project_id: project_id,
+ access_level: level
+ }
end
[remove, add]
diff --git a/app/services/boards/base_item_move_service.rb b/app/services/boards/base_item_move_service.rb
index 9d711d83fd2..c9da889c536 100644
--- a/app/services/boards/base_item_move_service.rb
+++ b/app/services/boards/base_item_move_service.rb
@@ -2,6 +2,8 @@
module Boards
class BaseItemMoveService < Boards::BaseService
+ LIST_END_POSITION = -1
+
def execute(issuable)
issuable_modification_params = issuable_params(issuable)
return if issuable_modification_params.empty?
@@ -32,7 +34,13 @@ module Boards
)
end
- reposition_ids = move_between_ids(params)
+ move_params = if params[:position_in_list].present?
+ move_params_from_list_position(params[:position_in_list])
+ else
+ params
+ end
+
+ reposition_ids = move_between_ids(move_params)
attrs.merge!(reposition_params(reposition_ids)) if reposition_ids
attrs
@@ -90,6 +98,18 @@ module Boards
::Label.ids_on_board(board.id)
end
+ def move_params_from_list_position(position)
+ if position == LIST_END_POSITION
+ { move_before_id: moving_to_list_items_relation.reverse_order.pick(:id), move_after_id: nil }
+ else
+ item_at_position = moving_to_list_items_relation.offset(position).pick(:id) # rubocop: disable CodeReuse/ActiveRecord
+
+ return move_params_from_list_position(LIST_END_POSITION) if item_at_position.nil?
+
+ { move_before_id: nil, move_after_id: item_at_position }
+ end
+ end
+
def move_between_ids(move_params)
ids = [move_params[:move_before_id], move_params[:move_after_id]]
.map(&:to_i)
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
index 90226b9d4e0..4de4d7c8f69 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -54,6 +54,10 @@ module Boards
def update(issue, issue_modification_params)
::Issues::UpdateService.new(project: issue.project, current_user: current_user, params: issue_modification_params).execute(issue)
end
+
+ def moving_to_list_items_relation
+ Boards::Issues::ListService.new(board.resource_parent, current_user, board_id: board.id, id: moving_to_list.id).execute
+ end
end
end
end
diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb
index a2c8ba5b1cd..45f1350df92 100644
--- a/app/services/bulk_imports/file_download_service.rb
+++ b/app/services/bulk_imports/file_download_service.rb
@@ -10,10 +10,11 @@
# @param filename [String] Name of the file to download, if known. Use remote filename if none given.
module BulkImports
class FileDownloadService
+ include ::BulkImports::FileDownloads::FilenameFetch
+ include ::BulkImports::FileDownloads::Validations
+
ServiceError = Class.new(StandardError)
- REMOTE_FILENAME_PATTERN = %r{filename="(?<filename>[^"]+)"}.freeze
- FILENAME_SIZE_LIMIT = 255 # chars before the extension
DEFAULT_FILE_SIZE_LIMIT = 5.gigabytes
DEFAULT_ALLOWED_CONTENT_TYPES = %w(application/gzip application/octet-stream).freeze
@@ -74,6 +75,10 @@ module BulkImports
raise e
end
+ def raise_error(message)
+ raise ServiceError, message
+ end
+
def http_client
@http_client ||= BulkImports::Clients::HTTP.new(
url: configuration.url,
@@ -85,24 +90,20 @@ module BulkImports
::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services?
end
- def headers
- @headers ||= http_client.head(relative_url).headers
- end
-
- def validate_filepath
- Gitlab::Utils.check_path_traversal!(filepath)
+ def response_headers
+ @response_headers ||= http_client.head(relative_url).headers
end
def validate_tmpdir
Gitlab::Utils.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir])
end
- def validate_symlink
- if File.lstat(filepath).symlink?
- File.delete(filepath)
+ def filepath
+ @filepath ||= File.join(@tmpdir, filename)
+ end
- raise(ServiceError, 'Invalid downloaded file')
- end
+ def filename
+ @filename.presence || remote_filename
end
def validate_url
@@ -113,61 +114,5 @@ module BulkImports
schemes: %w(http https)
)
end
-
- def validate_content_length
- validate_size!(headers['content-length'])
- end
-
- def validate_size!(size)
- if size.blank?
- raise ServiceError, 'Missing content-length header'
- elsif size.to_i > file_size_limit
- raise ServiceError, "File size %{size} exceeds limit of %{limit}" % {
- size: ActiveSupport::NumberHelper.number_to_human_size(size),
- limit: ActiveSupport::NumberHelper.number_to_human_size(file_size_limit)
- }
- end
- end
-
- def validate_content_type
- content_type = headers['content-type']
-
- raise(ServiceError, 'Invalid content type') if content_type.blank? || allowed_content_types.exclude?(content_type)
- end
-
- def filepath
- @filepath ||= File.join(@tmpdir, filename)
- end
-
- def filename
- @filename.presence || remote_filename
- end
-
- # Fetch the remote filename information from the request content-disposition header
- # - Raises if the filename does not exist
- # - If the filename is longer then 255 chars truncate it
- # to be a total of 255 chars (with the extension)
- def remote_filename
- @remote_filename ||=
- headers['content-disposition'].to_s
- .match(REMOTE_FILENAME_PATTERN) # matches the filename pattern
- .then { |match| match&.named_captures || {} } # ensures the match is a hash
- .fetch('filename') # fetches the 'filename' key or raise KeyError
- .then(&File.method(:basename)) # Ensures to remove path from the filename (../ for instance)
- .then(&method(:ensure_filename_size)) # Ensures the filename is within the FILENAME_SIZE_LIMIT
- rescue KeyError
- raise ServiceError, 'Remote filename not provided in content-disposition header'
- end
-
- def ensure_filename_size(filename)
- if filename.length <= FILENAME_SIZE_LIMIT
- filename
- else
- extname = File.extname(filename)
- basename = File.basename(filename, extname)[0, FILENAME_SIZE_LIMIT]
-
- "#{basename}#{extname}"
- end
- end
end
end
diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb
index c43f0d8cb4f..b1efa881180 100644
--- a/app/services/bulk_imports/relation_export_service.rb
+++ b/app/services/bulk_imports/relation_export_service.rb
@@ -65,7 +65,7 @@ module BulkImports
def export_service
@export_service ||= if config.tree_relation?(relation) || config.self_relation?(relation)
- TreeExportService.new(portable, config.export_path, relation)
+ TreeExportService.new(portable, config.export_path, relation, user)
elsif config.file_relation?(relation)
FileExportService.new(portable, config.export_path, relation)
else
diff --git a/app/services/bulk_imports/tree_export_service.rb b/app/services/bulk_imports/tree_export_service.rb
index 8e885e590d1..b6f094da558 100644
--- a/app/services/bulk_imports/tree_export_service.rb
+++ b/app/services/bulk_imports/tree_export_service.rb
@@ -2,11 +2,12 @@
module BulkImports
class TreeExportService
- def initialize(portable, export_path, relation)
+ def initialize(portable, export_path, relation, user)
@portable = portable
@export_path = export_path
@relation = relation
@config = FileTransfer.config_for(portable)
+ @user = user
end
def execute
@@ -27,7 +28,7 @@ module BulkImports
private
- attr_reader :export_path, :portable, :relation, :config
+ attr_reader :export_path, :portable, :relation, :config, :user
# rubocop: disable CodeReuse/Serializer
def serializer
@@ -35,7 +36,8 @@ module BulkImports
portable,
config.portable_tree,
json_writer,
- exportable_path: ''
+ exportable_path: '',
+ current_user: user
)
end
# rubocop: enable CodeReuse/Serializer
diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb
index 1ae4639751b..634c547a623 100644
--- a/app/services/ci/after_requeue_job_service.rb
+++ b/app/services/ci/after_requeue_job_service.rb
@@ -21,9 +21,16 @@ module Ci
@processable.pipeline.reset_source_bridge!(current_user)
end
+ # rubocop: disable CodeReuse/ActiveRecord
def dependent_jobs
+ return legacy_dependent_jobs unless ::Feature.enabled?(:ci_requeue_with_dag_object_hierarchy, project)
+
ordered_by_dag(
- stage_dependent_jobs.or(needs_dependent_jobs).ordered_by_stage
+ ::Ci::Processable
+ .from_union(needs_dependent_jobs, stage_dependent_jobs)
+ .skipped
+ .ordered_by_stage
+ .preload(:needs)
)
end
@@ -34,22 +41,37 @@ module Ci
end
def stage_dependent_jobs
- skipped_jobs.after_stage(@processable.stage_idx)
+ @processable.pipeline.processables.after_stage(@processable.stage_idx)
end
def needs_dependent_jobs
- skipped_jobs.scheduling_type_dag.with_needs([@processable.name])
+ ::Gitlab::Ci::ProcessableObjectHierarchy.new(
+ ::Ci::Processable.where(id: @processable.id)
+ ).descendants
end
- def skipped_jobs
- @skipped_jobs ||= @processable.pipeline.processables.skipped
+ def legacy_skipped_jobs
+ @legacy_skipped_jobs ||= @processable.pipeline.processables.skipped
+ end
+
+ def legacy_dependent_jobs
+ ordered_by_dag(
+ legacy_stage_dependent_jobs.or(legacy_needs_dependent_jobs).ordered_by_stage.preload(:needs)
+ )
+ end
+
+ def legacy_stage_dependent_jobs
+ legacy_skipped_jobs.after_stage(@processable.stage_idx)
+ end
+
+ def legacy_needs_dependent_jobs
+ legacy_skipped_jobs.scheduling_type_dag.with_needs([@processable.name])
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|
+ jobs.group_by(&:stage_idx).flat_map do |_, stage_jobs|
stage_jobs.sort_by { |job| sorted_job_names.fetch(job.name) }
end
end
diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb
index 9705a236d98..566346a4b09 100644
--- a/app/services/ci/archive_trace_service.rb
+++ b/app/services/ci/archive_trace_service.rb
@@ -27,7 +27,7 @@ module Ci
job.trace.archive!
job.remove_pending_state!
- if Feature.enabled?(:datadog_integration_logs_collection, job.project) && job.job_artifacts_trace.present?
+ if job.job_artifacts_trace.present?
job.project.execute_integrations(Gitlab::DataBuilder::ArchiveTrace.build(job), :archive_trace_hooks)
end
diff --git a/app/services/ci/build_erase_service.rb b/app/services/ci/build_erase_service.rb
new file mode 100644
index 00000000000..8a468e094eb
--- /dev/null
+++ b/app/services/ci/build_erase_service.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Ci
+ class BuildEraseService
+ include BaseServiceUtility
+
+ def initialize(build, current_user)
+ @build = build
+ @current_user = current_user
+ end
+
+ def execute
+ unless build.erasable?
+ return ServiceResponse.error(message: _('Build cannot be erased'), http_status: :unprocessable_entity)
+ end
+
+ if build.project.refreshing_build_artifacts_size?
+ Gitlab::ProjectStatsRefreshConflictsLogger.warn_artifact_deletion_during_stats_refresh(
+ method: 'Ci::BuildEraseService#execute',
+ project_id: build.project_id
+ )
+ end
+
+ destroy_artifacts
+ erase_trace!
+ update_erased!
+
+ ServiceResponse.success(payload: build)
+ end
+
+ private
+
+ attr_reader :build, :current_user
+
+ def destroy_artifacts
+ # fix_expire_at is false because in this case we want to explicitly delete the job artifacts
+ # this flag is a workaround that will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/355833
+ Ci::JobArtifacts::DestroyBatchService.new(build.job_artifacts, fix_expire_at: false).execute
+ end
+
+ def erase_trace!
+ build.trace.erase!
+ end
+
+ def update_erased!
+ build.update(erased_by: current_user, erased_at: Time.current, artifacts_expire_at: nil)
+ end
+ end
+end
diff --git a/app/services/ci/build_report_result_service.rb b/app/services/ci/build_report_result_service.rb
index f9146b3677a..20a31322919 100644
--- a/app/services/ci/build_report_result_service.rb
+++ b/app/services/ci/build_report_result_service.rb
@@ -22,7 +22,8 @@ module Ci
private
def generate_test_suite_report(build)
- build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
+ test_report = build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
+ test_report.get_suite(build.test_suite_name)
end
def tests_params(test_suite)
diff --git a/app/services/ci/compare_reports_base_service.rb b/app/services/ci/compare_reports_base_service.rb
index 9aba3a50ec1..ee687706b53 100644
--- a/app/services/ci/compare_reports_base_service.rb
+++ b/app/services/ci/compare_reports_base_service.rb
@@ -8,6 +8,8 @@ module Ci
# issue: https://gitlab.com/gitlab-org/gitlab/issues/34224
class CompareReportsBaseService < ::BaseService
def execute(base_pipeline, head_pipeline)
+ return parsing_payload(base_pipeline, head_pipeline) if base_pipeline&.running?
+
base_report = get_report(base_pipeline)
head_report = get_report(head_pipeline)
comparer = build_comparer(base_report, head_report)
@@ -33,6 +35,13 @@ module Ci
protected
+ def parsing_payload(base_pipeline, head_pipeline)
+ {
+ status: :parsing,
+ key: key(base_pipeline, head_pipeline)
+ }
+ end
+
def build_comparer(base_report, head_report)
comparer_class.new(base_report, head_report)
end
diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb
index b38b3b93353..25cc9045052 100644
--- a/app/services/ci/create_downstream_pipeline_service.rb
+++ b/app/services/ci/create_downstream_pipeline_service.rb
@@ -11,6 +11,7 @@ module Ci
DuplicateDownstreamPipelineError = Class.new(StandardError)
MAX_NESTED_CHILDREN = 2
+ MAX_HIERARCHY_SIZE = 1000
def execute(bridge)
@bridge = bridge
@@ -86,6 +87,11 @@ module Ci
return false
end
+ if Feature.enabled?(:ci_limit_complete_hierarchy_size) && pipeline_tree_too_large?
+ @bridge.drop!(:reached_max_pipeline_hierarchy_size)
+ return false
+ end
+
unless can_create_downstream_pipeline?(target_ref)
@bridge.drop!(:insufficient_bridge_permissions)
return false
@@ -137,10 +143,17 @@ module Ci
return false unless @bridge.triggers_child_pipeline?
# only applies to parent-child pipelines not multi-project
- ancestors_of_new_child = @bridge.pipeline.self_and_ancestors
+ ancestors_of_new_child = @bridge.pipeline.self_and_project_ancestors
ancestors_of_new_child.count > MAX_NESTED_CHILDREN
end
+ def pipeline_tree_too_large?
+ return false unless @bridge.triggers_downstream_pipeline?
+
+ # Applies to the entire pipeline tree across all projects
+ @bridge.pipeline.complete_hierarchy_count >= MAX_HIERARCHY_SIZE
+ end
+
def config_checksum(pipeline)
[pipeline.project_id, pipeline.ref, pipeline.source].hash
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 02f25a82307..af175b8da1c 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -23,6 +23,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs,
Gitlab::Ci::Pipeline::Chain::SeedBlock,
Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules,
+ Gitlab::Ci::Pipeline::Chain::AssignPartition,
Gitlab::Ci::Pipeline::Chain::Seed,
Gitlab::Ci::Pipeline::Chain::Limit::Size,
Gitlab::Ci::Pipeline::Chain::Limit::Deployments,
diff --git a/app/services/ci/delete_objects_service.rb b/app/services/ci/delete_objects_service.rb
index bac99abadc9..7a93d0e9665 100644
--- a/app/services/ci/delete_objects_service.rb
+++ b/app/services/ci/delete_objects_service.rb
@@ -27,9 +27,7 @@ module Ci
# `find_by_sql` performs a write in this case and we need to wrap it in
# a transaction to stick to the primary database.
Ci::DeletedObject.transaction do
- Ci::DeletedObject.find_by_sql([
- next_batch_sql, new_pick_up_at: RETRY_IN.from_now
- ])
+ Ci::DeletedObject.find_by_sql([next_batch_sql, new_pick_up_at: RETRY_IN.from_now])
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb
index bf2355c447a..15597eb7209 100644
--- a/app/services/ci/expire_pipeline_cache_service.rb
+++ b/app/services/ci/expire_pipeline_cache_service.rb
@@ -86,7 +86,7 @@ module Ci
etag_paths << path
end
- pipeline.all_pipelines_in_hierarchy.includes(project: [:route, { namespace: :route }]).each do |relative_pipeline| # rubocop: disable CodeReuse/ActiveRecord
+ pipeline.upstream_and_all_downstreams.includes(project: [:route, { namespace: :route }]).each do |relative_pipeline| # rubocop: disable CodeReuse/ActiveRecord
etag_paths << project_pipeline_path(relative_pipeline.project, relative_pipeline)
etag_paths << graphql_pipeline_path(relative_pipeline)
etag_paths << graphql_pipeline_sha_path(relative_pipeline.sha)
diff --git a/app/services/ci/generate_coverage_reports_service.rb b/app/services/ci/generate_coverage_reports_service.rb
index 81f26e84ef8..8beecb79fd9 100644
--- a/app/services/ci/generate_coverage_reports_service.rb
+++ b/app/services/ci/generate_coverage_reports_service.rb
@@ -43,7 +43,7 @@ module Ci
end
def last_update_timestamp(pipeline_hierarchy)
- pipeline_hierarchy&.self_and_descendants&.maximum(:updated_at)
+ pipeline_hierarchy&.self_and_project_descendants&.maximum(:updated_at)
end
end
end
diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb
index af56eb221d5..3dc097a8603 100644
--- a/app/services/ci/job_artifacts/create_service.rb
+++ b/app/services/ci/job_artifacts/create_service.rb
@@ -80,7 +80,7 @@ module Ci
Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in
artifact_attributes = {
- job_id: job.id,
+ job: job,
project: project,
expire_in: expire_in
}
diff --git a/app/services/ci/job_artifacts/delete_service.rb b/app/services/ci/job_artifacts/delete_service.rb
new file mode 100644
index 00000000000..65cae03312e
--- /dev/null
+++ b/app/services/ci/job_artifacts/delete_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobArtifacts
+ class DeleteService
+ include BaseServiceUtility
+
+ def initialize(build)
+ @build = build
+ end
+
+ def execute
+ if build.project.refreshing_build_artifacts_size?
+ Gitlab::ProjectStatsRefreshConflictsLogger.warn_artifact_deletion_during_stats_refresh(
+ method: 'Ci::JobArtifacts::DeleteService#execute',
+ project_id: build.project_id
+ )
+ end
+
+ # fix_expire_at is false because in this case we want to explicitly delete the job artifacts
+ # this flag is a workaround that will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/355833
+ Ci::JobArtifacts::DestroyBatchService.new(build.job_artifacts.erasable, fix_expire_at: false).execute
+
+ ServiceResponse.success
+ end
+
+ private
+
+ attr_reader :build
+ end
+ end
+end
diff --git a/app/services/ci/job_artifacts/track_artifact_report_service.rb b/app/services/ci/job_artifacts/track_artifact_report_service.rb
new file mode 100644
index 00000000000..1be1d98394f
--- /dev/null
+++ b/app/services/ci/job_artifacts/track_artifact_report_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobArtifacts
+ class TrackArtifactReportService
+ include Gitlab::Utils::UsageData
+
+ REPORT_TRACKED = %i[test].freeze
+
+ def execute(pipeline)
+ REPORT_TRACKED.each do |report|
+ if pipeline.complete_and_has_reports?(Ci::JobArtifact.of_report_type(report))
+ track_usage_event(event_name(report), pipeline.user_id)
+ end
+ end
+ end
+
+ def event_name(report)
+ "i_testing_#{report}_report_uploaded"
+ end
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_artifacts/coverage_report_service.rb b/app/services/ci/pipeline_artifacts/coverage_report_service.rb
index c11a8f7a0fd..99877603554 100644
--- a/app/services/ci/pipeline_artifacts/coverage_report_service.rb
+++ b/app/services/ci/pipeline_artifacts/coverage_report_service.rb
@@ -27,12 +27,18 @@ module Ci
end
def pipeline_artifact_params
- {
+ attributes = {
pipeline: pipeline,
file_type: :code_coverage,
file: carrierwave_file,
size: carrierwave_file['tempfile'].size
}
+
+ if ::Feature.enabled?(:ci_update_unlocked_pipeline_artifacts, pipeline.project)
+ attributes[:locked] = pipeline.locked
+ end
+
+ attributes
end
def carrierwave_file
diff --git a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb
index d6865efac9f..aeb68a75f88 100644
--- a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb
+++ b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb
@@ -13,21 +13,31 @@ module Ci
return if pipeline.has_codequality_mr_diff_report?
return unless new_errors_introduced?
+ pipeline.pipeline_artifacts.create!(**artifact_attributes)
+ end
+
+ private
+
+ attr_reader :pipeline
+
+ def artifact_attributes
file = build_carrierwave_file!
- pipeline.pipeline_artifacts.create!(
+ attributes = {
project_id: pipeline.project_id,
file_type: :code_quality_mr_diff,
file_format: Ci::PipelineArtifact::REPORT_TYPES.fetch(:code_quality_mr_diff),
size: file["tempfile"].size,
file: file,
expire_at: Ci::PipelineArtifact::EXPIRATION_DATE.from_now
- )
- end
+ }
- private
+ if ::Feature.enabled?(:ci_update_unlocked_pipeline_artifacts, pipeline.project)
+ attributes[:locked] = pipeline.locked
+ end
- attr_reader :pipeline
+ attributes
+ end
def merge_requests
strong_memoize(:merge_requests) do
diff --git a/app/services/ci/pipelines/add_job_service.rb b/app/services/ci/pipelines/add_job_service.rb
index fc852bc3edd..dfbb37cf0dc 100644
--- a/app/services/ci/pipelines/add_job_service.rb
+++ b/app/services/ci/pipelines/add_job_service.rb
@@ -39,11 +39,13 @@ module Ci
job.pipeline = pipeline
job.project = pipeline.project
job.ref = pipeline.ref
+ job.partition_id = pipeline.partition_id
# update metadata since it might have been lazily initialised before this call
# metadata is present on `Ci::Processable`
if job.respond_to?(:metadata) && job.metadata
job.metadata.project = pipeline.project
+ job.metadata.partition_id = pipeline.partition_id
end
end
end
diff --git a/app/services/ci/queue/pending_builds_strategy.rb b/app/services/ci/queue/pending_builds_strategy.rb
index c8bdbba5e65..cfafe66d10b 100644
--- a/app/services/ci/queue/pending_builds_strategy.rb
+++ b/app/services/ci/queue/pending_builds_strategy.rb
@@ -19,7 +19,11 @@ module Ci
def builds_for_group_runner
return new_builds.none if runner.namespace_ids.empty?
- new_builds.where('ci_pending_builds.namespace_traversal_ids && ARRAY[?]::int[]', runner.namespace_ids)
+ new_builds_relation = new_builds.where('ci_pending_builds.namespace_traversal_ids && ARRAY[?]::int[]', runner.namespace_ids)
+
+ return order(new_builds_relation) if ::Feature.enabled?(:order_builds_for_group_runner)
+
+ new_builds_relation
end
def builds_matching_tag_ids(relation, ids)
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index b357855db12..0bd4bf8cc86 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -287,7 +287,7 @@ module Ci
Gitlab::ErrorTracking.track_exception(ex,
build_id: build.id,
build_name: build.name,
- build_stage: build.stage,
+ build_stage: build.stage_name,
pipeline_id: build.pipeline_id,
project_id: build.project_id
)
diff --git a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb
index dfd97498fc8..d7078200c14 100644
--- a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb
+++ b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb
@@ -9,8 +9,10 @@ module Ci
free_resources = resource_group.resources.free.count
- resource_group.upcoming_processables.take(free_resources).each do |processable|
- processable.enqueue_waiting_for_resource
+ resource_group.upcoming_processables.take(free_resources).each do |upcoming|
+ Gitlab::OptimisticLocking.retry_lock(upcoming, name: 'enqueue_waiting_for_resource') do |processable|
+ processable.enqueue_waiting_for_resource
+ end
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/ci/runners/set_runner_associated_projects_service.rb b/app/services/ci/runners/set_runner_associated_projects_service.rb
new file mode 100644
index 00000000000..7930776749d
--- /dev/null
+++ b/app/services/ci/runners/set_runner_associated_projects_service.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class SetRunnerAssociatedProjectsService
+ # @param [Ci::Runner] runner: the project runner to assign/unassign projects from
+ # @param [User] current_user: the user performing the operation
+ # @param [Array<Integer>] project_ids: the IDs of the associated projects to assign the runner to
+ def initialize(runner:, current_user:, project_ids:)
+ @runner = runner
+ @current_user = current_user
+ @project_ids = project_ids
+ end
+
+ def execute
+ unless current_user&.can?(:assign_runner, runner)
+ return ServiceResponse.error(message: 'user not allowed to assign runner', http_status: :forbidden)
+ end
+
+ return ServiceResponse.success if project_ids.blank?
+
+ set_associated_projects
+ end
+
+ private
+
+ def set_associated_projects
+ new_project_ids = [runner.owner_project.id] + project_ids
+
+ response = ServiceResponse.success
+ runner.transaction do
+ # rubocop:disable CodeReuse/ActiveRecord
+ current_project_ids = runner.projects.ids
+ # rubocop:enable CodeReuse/ActiveRecord
+
+ unless associate_new_projects(new_project_ids, current_project_ids)
+ response = ServiceResponse.error(message: 'failed to assign projects to runner')
+ raise ActiveRecord::Rollback, response.errors
+ end
+
+ unless disassociate_old_projects(new_project_ids, current_project_ids)
+ response = ServiceResponse.error(message: 'failed to destroy runner project')
+ raise ActiveRecord::Rollback, response.errors
+ end
+ end
+
+ response
+ end
+
+ def associate_new_projects(new_project_ids, current_project_ids)
+ missing_projects = Project.id_in(new_project_ids - current_project_ids)
+ missing_projects.all? { |project| runner.assign_to(project, current_user) }
+ end
+
+ def disassociate_old_projects(new_project_ids, current_project_ids)
+ projects_to_be_deleted = current_project_ids - new_project_ids
+ return true if projects_to_be_deleted.empty?
+
+ Ci::RunnerProject
+ .destroy_by(project_id: projects_to_be_deleted)
+ .all?(&:destroyed?)
+ end
+
+ attr_reader :runner, :current_user, :project_ids
+ end
+ end
+end
+
+Ci::Runners::SetRunnerAssociatedProjectsService.prepend_mod
diff --git a/app/services/ci/runners/update_runner_service.rb b/app/services/ci/runners/update_runner_service.rb
index 6cc080f81c2..bd01f52f396 100644
--- a/app/services/ci/runners/update_runner_service.rb
+++ b/app/services/ci/runners/update_runner_service.rb
@@ -9,11 +9,14 @@ module Ci
@runner = runner
end
- def update(params)
+ def execute(params)
params[:active] = !params.delete(:paused) if params.include?(:paused)
- runner.update(params).tap do |updated|
- runner.tick_runner_queue if updated
+ if runner.update(params)
+ runner.tick_runner_queue
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: runner.errors.full_messages)
end
end
end
diff --git a/app/services/ci/stuck_builds/drop_helpers.rb b/app/services/ci/stuck_builds/drop_helpers.rb
index dca50963883..f56c9aaeb55 100644
--- a/app/services/ci/stuck_builds/drop_helpers.rb
+++ b/app/services/ci/stuck_builds/drop_helpers.rb
@@ -48,7 +48,7 @@ module Ci
Gitlab::ErrorTracking.track_exception(ex,
build_id: build.id,
build_name: build.name,
- build_stage: build.stage,
+ build_stage: build.stage_name,
pipeline_id: build.pipeline_id,
project_id: build.project_id
)
diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb
index 2214a6a2729..5a8072b2a0d 100644
--- a/app/services/ci/test_failure_history_service.rb
+++ b/app/services/ci/test_failure_history_service.rb
@@ -80,8 +80,8 @@ module Ci
end
def generate_test_suite!(build)
- # Returns an instance of Gitlab::Ci::Reports::TestSuite
- build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
+ test_report = build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
+ test_report.get_suite(build.test_suite_name)
end
def ci_unit_test_attrs(batch)
diff --git a/app/services/ci/unlock_artifacts_service.rb b/app/services/ci/unlock_artifacts_service.rb
index 30da31ba8ec..1fee31da4fc 100644
--- a/app/services/ci/unlock_artifacts_service.rb
+++ b/app/services/ci/unlock_artifacts_service.rb
@@ -7,9 +7,12 @@ module Ci
def execute(ci_ref, before_pipeline = nil)
results = {
unlocked_pipelines: 0,
- unlocked_job_artifacts: 0
+ unlocked_job_artifacts: 0,
+ unlocked_pipeline_artifacts: 0
}
+ unlock_pipeline_artifacts_enabled = ::Feature.enabled?(:ci_update_unlocked_pipeline_artifacts, ci_ref.project)
+
if ::Feature.enabled?(:ci_update_unlocked_job_artifacts, ci_ref.project)
loop do
unlocked_pipelines = []
@@ -18,6 +21,10 @@ module Ci
::Ci::Pipeline.transaction do
unlocked_pipelines = unlock_pipelines(ci_ref, before_pipeline)
unlocked_job_artifacts = unlock_job_artifacts(unlocked_pipelines)
+
+ if unlock_pipeline_artifacts_enabled
+ results[:unlocked_pipeline_artifacts] += unlock_pipeline_artifacts(unlocked_pipelines)
+ end
end
break if unlocked_pipelines.empty?
@@ -100,6 +107,14 @@ module Ci
)
end
+ # rubocop:disable CodeReuse/ActiveRecord
+ def unlock_pipeline_artifacts(pipelines)
+ return 0 if pipelines.empty?
+
+ ::Ci::PipelineArtifact.where(pipeline_id: pipelines.rows.flatten).update_all(locked: :unlocked)
+ end
+ # rubocop:enable CodeReuse/ActiveRecord
+
def unlock_pipelines(ci_ref, before_pipeline)
::Ci::Pipeline.connection.exec_query(unlock_pipelines_query(ci_ref, before_pipeline))
end
diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb
index fc18420f6e4..a498d39d34e 100644
--- a/app/services/commits/create_service.rb
+++ b/app/services/commits/create_service.rb
@@ -66,7 +66,7 @@ module Commits
validate_on_branch!
validate_branch_existence!
- validate_new_branch_name! if different_branch?
+ validate_new_branch_name! if project.empty_repo? || different_branch?
end
def validate_permissions!
diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb
index 8c6c7b15d28..9fe82507edd 100644
--- a/app/services/concerns/alert_management/alert_processing.rb
+++ b/app/services/concerns/alert_management/alert_processing.rb
@@ -113,7 +113,7 @@ module AlertManagement
end
def resolving_alert?
- incoming_payload.ends_at.present?
+ incoming_payload.resolved?
end
def notifying_alert?
@@ -121,7 +121,7 @@ module AlertManagement
end
def alert_source
- incoming_payload.monitoring_tool
+ incoming_payload.source
end
def logger
diff --git a/app/services/concerns/ci/downstream_pipeline_helpers.rb b/app/services/concerns/ci/downstream_pipeline_helpers.rb
index 39c0adb6e4e..26d7eb97151 100644
--- a/app/services/concerns/ci/downstream_pipeline_helpers.rb
+++ b/app/services/concerns/ci/downstream_pipeline_helpers.rb
@@ -5,7 +5,6 @@ module Ci
def log_downstream_pipeline_creation(downstream_pipeline)
return unless downstream_pipeline&.persisted?
- hierarchy_size = downstream_pipeline.all_pipelines_in_hierarchy.count
root_pipeline = downstream_pipeline.upstream_root
::Gitlab::AppLogger.info(
@@ -14,7 +13,7 @@ module Ci
root_pipeline_id: root_pipeline.id,
downstream_pipeline_id: downstream_pipeline.id,
downstream_pipeline_relationship: downstream_pipeline.parent_pipeline? ? :parent_child : :multi_project,
- hierarchy_size: hierarchy_size,
+ hierarchy_size: downstream_pipeline.complete_hierarchy_count,
root_pipeline_plan: root_pipeline.project.actual_plan_name,
root_pipeline_namespace_path: root_pipeline.project.namespace.full_path,
root_pipeline_project_path: root_pipeline.project.full_path
diff --git a/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb b/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb
index 23053975313..427aebf397e 100644
--- a/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb
+++ b/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb
@@ -9,10 +9,6 @@ module Ci
"not exist or you don't have permission to perform this action"
def validate_edit!(source_project, target_project, current_user)
- unless source_project.ci_job_token_scope_enabled?
- raise ValidationError, "Job token scope is disabled for this project"
- end
-
unless can?(current_user, :admin_project, source_project)
raise ValidationError, "Insufficient permissions to modify the job token scope"
end
diff --git a/app/services/concerns/projects/container_repository/gitlab/timeoutable.rb b/app/services/concerns/projects/container_repository/gitlab/timeoutable.rb
new file mode 100644
index 00000000000..095f5aa7cfa
--- /dev/null
+++ b/app/services/concerns/projects/container_repository/gitlab/timeoutable.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Projects
+ module ContainerRepository
+ module Gitlab
+ module Timeoutable
+ extend ActiveSupport::Concern
+
+ DISABLED_TIMEOUTS = [nil, 0].freeze
+
+ TimeoutError = Class.new(StandardError)
+
+ private
+
+ def timeout?(start_time)
+ return false if service_timeout.in?(DISABLED_TIMEOUTS)
+
+ (Time.zone.now - start_time) > service_timeout
+ end
+
+ def service_timeout
+ ::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/container_expiration_policies/cleanup_service.rb b/app/services/container_expiration_policies/cleanup_service.rb
index 34889e58127..1123b29f217 100644
--- a/app/services/container_expiration_policies/cleanup_service.rb
+++ b/app/services/container_expiration_policies/cleanup_service.rb
@@ -24,7 +24,7 @@ module ContainerExpirationPolicies
begin
service_result = Projects::ContainerRepository::CleanupTagsService
- .new(repository, nil, policy_params.merge('container_expiration_policy' => true))
+ .new(container_repository: repository, params: policy_params.merge('container_expiration_policy' => true))
.execute
rescue StandardError
repository.cleanup_unfinished!
diff --git a/app/services/deployments/update_environment_service.rb b/app/services/deployments/update_environment_service.rb
index 3cacedc7d6e..90a31ae9370 100644
--- a/app/services/deployments/update_environment_service.rb
+++ b/app/services/deployments/update_environment_service.rb
@@ -61,6 +61,12 @@ module Deployments
ExpandVariables.expand(environment_url, -> { variables.sort_and_expand_all })
end
+ def expanded_auto_stop_in
+ return unless auto_stop_in
+
+ ExpandVariables.expand(auto_stop_in, -> { variables.sort_and_expand_all })
+ end
+
def environment_url
environment_options[:url]
end
@@ -69,6 +75,10 @@ module Deployments
environment_options[:action] || 'start'
end
+ def auto_stop_in
+ deployable&.environment_auto_stop_in
+ end
+
def renew_external_url
if (url = expanded_environment_url)
environment.external_url = url
@@ -78,7 +88,9 @@ module Deployments
def renew_auto_stop_in
return unless deployable
- environment.auto_stop_in = deployable.environment_auto_stop_in
+ if (value = expanded_auto_stop_in)
+ environment.auto_stop_in = value
+ end
end
def renew_deployment_tier
diff --git a/app/services/design_management/copy_design_collection/copy_service.rb b/app/services/design_management/copy_design_collection/copy_service.rb
index 886077191ab..3bc30f62a81 100644
--- a/app/services/design_management/copy_design_collection/copy_service.rb
+++ b/app/services/design_management/copy_design_collection/copy_service.rb
@@ -143,7 +143,7 @@ module DesignManagement
gitaly_actions = version.actions.map do |action|
design = action.design
# Map the raw Action#event enum value to a Gitaly "action" for the
- # `Repository#multi_action` call.
+ # `Repository#commit_files` call.
gitaly_action_name = @event_enum_map[action.event_before_type_cast]
# `content` will be the LfsPointer file and not the design file,
# and can be nil for deletions.
@@ -157,7 +157,7 @@ module DesignManagement
}.compact
end
- sha = target_repository.multi_action(
+ sha = target_repository.commit_files(
git_user,
branch_name: temporary_branch,
message: commit_message(version),
diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb
index 9ed03a994c4..921c904d8de 100644
--- a/app/services/design_management/delete_designs_service.rb
+++ b/app/services/design_management/delete_designs_service.rb
@@ -16,7 +16,8 @@ module DesignManagement
version = delete_designs!
EventCreateService.new.destroy_designs(designs, current_user)
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_removed_action(author: current_user)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_removed_action(author: current_user,
+ project: project)
TodosDestroyer::DestroyedDesignsWorker.perform_async(designs.map(&:id))
success(version: version)
diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb
index ee6aa9286d3..267ed6bf29f 100644
--- a/app/services/design_management/runs_design_actions.rb
+++ b/app/services/design_management/runs_design_actions.rb
@@ -15,7 +15,7 @@ module DesignManagement
def run_actions(actions, skip_system_notes: false)
raise NoActions if actions.empty?
- sha = repository.multi_action(current_user,
+ sha = repository.commit_files(current_user,
branch_name: target_branch,
message: commit_message,
actions: actions.map(&:gitaly_action))
diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb
index a1fce45434b..64537293e65 100644
--- a/app/services/design_management/save_designs_service.rb
+++ b/app/services/design_management/save_designs_service.rb
@@ -131,9 +131,11 @@ module DesignManagement
def track_usage_metrics(action)
if action == :update
- ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_modified_action(author: current_user)
+ ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_modified_action(author: current_user,
+ project: project)
else
- ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_added_action(author: current_user)
+ ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_added_action(author: current_user,
+ project: project)
end
::Gitlab::UsageDataCounters::DesignsCounter.count(action)
diff --git a/app/services/environments/stop_service.rb b/app/services/environments/stop_service.rb
index 75c878c9350..774e3ffe273 100644
--- a/app/services/environments/stop_service.rb
+++ b/app/services/environments/stop_service.rb
@@ -25,8 +25,19 @@ module Environments
def execute_for_merge_request_pipeline(merge_request)
return unless merge_request.actual_head_pipeline&.merge_request?
- merge_request.environments_in_head_pipeline(deployment_status: :success).each do |environment|
- execute(environment)
+ created_environments = merge_request.created_environments
+
+ if created_environments.any?
+ created_environments.each { |env| execute(env) }
+ else
+ environments_in_head_pipeline = merge_request.environments_in_head_pipeline(deployment_status: :success)
+ environments_in_head_pipeline.each { |env| execute(env) }
+
+ if environments_in_head_pipeline.any?
+ # If we don't see a message often, we'd be able to remove this path. (or likely in GitLab 16.0)
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/372965
+ Gitlab::AppJsonLogger.info(message: 'Running legacy dynamic environment stop logic', project_id: project.id)
+ end
end
end
diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb
index 65af4dd5a28..dd09ecafb4f 100644
--- a/app/services/files/multi_service.rb
+++ b/app/services/files/multi_service.rb
@@ -38,7 +38,7 @@ module Files
end
def commit_actions!(actions)
- repository.multi_action(
+ repository.commit_files(
current_user,
message: @commit_message,
branch_name: @branch_name,
diff --git a/app/services/google_cloud/create_cloudsql_instance_service.rb b/app/services/google_cloud/create_cloudsql_instance_service.rb
index f7fca277c52..8d040c6c908 100644
--- a/app/services/google_cloud/create_cloudsql_instance_service.rb
+++ b/app/services/google_cloud/create_cloudsql_instance_service.rb
@@ -11,7 +11,7 @@ module GoogleCloud
trigger_instance_setup_worker
success
rescue Google::Apis::Error => err
- error(err.to_json)
+ error(err.message)
end
private
diff --git a/app/services/google_cloud/enable_cloudsql_service.rb b/app/services/google_cloud/enable_cloudsql_service.rb
index a466b2f3696..e4a411d0fab 100644
--- a/app/services/google_cloud/enable_cloudsql_service.rb
+++ b/app/services/google_cloud/enable_cloudsql_service.rb
@@ -12,6 +12,8 @@ module GoogleCloud
end
success({ gcp_project_ids: unique_gcp_project_ids })
+ rescue Google::Apis::Error => err
+ error(err.message)
end
private
diff --git a/app/services/google_cloud/fetch_google_ip_list_service.rb b/app/services/google_cloud/fetch_google_ip_list_service.rb
new file mode 100644
index 00000000000..f7739971603
--- /dev/null
+++ b/app/services/google_cloud/fetch_google_ip_list_service.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+ class FetchGoogleIpListService
+ include BaseServiceUtility
+
+ GOOGLE_IP_RANGES_URL = 'https://www.gstatic.com/ipranges/cloud.json'
+ RESPONSE_BODY_LIMIT = 1.megabyte
+ EXPECTED_CONTENT_TYPE = 'application/json'
+
+ IpListNotRetrievedError = Class.new(StandardError)
+
+ def execute
+ # Prevent too many workers from hitting the same HTTP endpoint
+ if ::Gitlab::ApplicationRateLimiter.throttled?(:fetch_google_ip_list, scope: nil)
+ return error("#{self.class} was rate limited")
+ end
+
+ subnets = fetch_and_update_cache!
+
+ Gitlab::AppJsonLogger.info(class: self.class.name,
+ message: 'Successfully retrieved Google IP list',
+ subnet_count: subnets.count)
+
+ success({ subnets: subnets })
+ rescue IpListNotRetrievedError => err
+ Gitlab::ErrorTracking.log_exception(err)
+ error('Google IP list not retrieved')
+ end
+
+ private
+
+ # Attempts to retrieve and parse the list of IPs from Google. Updates
+ # the internal cache so that the data is accessible.
+ #
+ # Returns an array of IPAddr objects consisting of subnets.
+ def fetch_and_update_cache!
+ parsed_response = fetch_google_ip_list
+
+ parse_google_prefixes(parsed_response).tap do |subnets|
+ ::ObjectStorage::CDN::GoogleIpCache.update!(subnets)
+ end
+ end
+
+ def fetch_google_ip_list
+ response = Gitlab::HTTP.get(GOOGLE_IP_RANGES_URL, follow_redirects: false, allow_local_requests: false)
+
+ validate_response!(response)
+
+ response.parsed_response
+ end
+
+ def validate_response!(response)
+ raise IpListNotRetrievedError, "response was #{response.code}" unless response.code == 200
+ raise IpListNotRetrievedError, "response was nil" unless response.body
+
+ parsed_response = response.parsed_response
+
+ unless response.content_type == EXPECTED_CONTENT_TYPE && parsed_response.is_a?(Hash)
+ raise IpListNotRetrievedError, "response was not JSON"
+ end
+
+ if response.body&.bytesize.to_i > RESPONSE_BODY_LIMIT
+ raise IpListNotRetrievedError, "response was too large: #{response.body.bytesize}"
+ end
+
+ prefixes = parsed_response['prefixes']
+
+ raise IpListNotRetrievedError, "JSON was type #{prefixes.class}, expected Array" unless prefixes.is_a?(Array)
+ raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if prefixes.empty?
+
+ response.parsed_response
+ end
+
+ def parse_google_prefixes(parsed_response)
+ ranges = parsed_response['prefixes'].map do |prefix|
+ ip_range = prefix['ipv4Prefix'] || prefix['ipv6Prefix']
+
+ next unless ip_range
+
+ IPAddr.new(ip_range)
+ end.compact
+
+ raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if ranges.empty?
+
+ ranges
+ end
+ end
+end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 35716f7742a..d508865ef32 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -39,7 +39,7 @@ module Groups
if @group.save
@group.add_owner(current_user)
Integration.create_from_active_default_integrations(@group, :group_id)
- OnboardingProgress.onboard(@group)
+ Onboarding::Progress.onboard(@group)
end
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index ff5d5d2c4c1..53297d2412c 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -50,7 +50,7 @@ module Import
end
def project_name
- @project_name ||= params[:new_name].presence || repo.name
+ @project_name ||= params[:new_name].presence || repo[:name]
end
def namespace_path
@@ -66,13 +66,13 @@ module Import
end
def oversized?
- repository_size_limit > 0 && repo.size > repository_size_limit
+ repository_size_limit > 0 && repo[:size] > repository_size_limit
end
def oversize_error_message
_('"%{repository_name}" size (%{repository_size}) is larger than the limit of %{limit}.') % {
- repository_name: repo.name,
- repository_size: number_to_human_size(repo.size),
+ repository_name: repo[:name],
+ repository_size: number_to_human_size(repo[:size]),
limit: number_to_human_size(repository_size_limit)
}
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index acd6d45af7a..70ad97f8436 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -285,7 +285,7 @@ class IssuableBaseService < ::BaseProjectService
if issuable.changed? || params.present? || widget_params.present?
issuable.assign_attributes(allowed_update_params(params))
- if has_title_or_description_changed?(issuable)
+ if issuable.description_changed?
issuable.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user)
end
@@ -398,10 +398,6 @@ class IssuableBaseService < ::BaseProjectService
update_task(issuable)
end
- def has_title_or_description_changed?(issuable)
- issuable.title_changed? || issuable.description_changed?
- end
-
def change_additional_attributes(issuable)
change_state(issuable)
change_subscription(issuable)
diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb
index aca98596a02..2e9775af8c2 100644
--- a/app/services/issuable_links/create_service.rb
+++ b/app/services/issuable_links/create_service.rb
@@ -41,7 +41,7 @@ module IssuableLinks
set_link_type(link)
if link.changed? && link.save
- create_notes(referenced_issuable)
+ create_notes(link)
end
link
@@ -124,9 +124,9 @@ module IssuableLinks
:issue
end
- def create_notes(referenced_issuable)
- SystemNoteService.relate_issuable(issuable, referenced_issuable, current_user)
- SystemNoteService.relate_issuable(referenced_issuable, issuable, current_user)
+ def create_notes(issuable_link)
+ SystemNoteService.relate_issuable(issuable_link.source, issuable_link.target, current_user)
+ SystemNoteService.relate_issuable(issuable_link.target, issuable_link.source, current_user)
end
def linkable_issuables(objects)
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 61a95e49228..d75e74f3b19 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -28,9 +28,6 @@ module Issues
return if issue.relative_position.nil?
return if NO_REBALANCING_NEEDED.cover?(issue.relative_position)
- gates = [issue.project, issue.project.group].compact
- return unless gates.any? { |gate| Feature.enabled?(:rebalance_issues, gate) }
-
Issues::RebalancingWorker.perform_async(nil, *issue.project.self_or_root_group_ids)
end
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index d08e4d12a92..da888386e0a 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -24,7 +24,7 @@ module Issues
return issue
end
- if perform_close(issue)
+ if issue.close(current_user)
event_service.close_issue(issue, current_user)
create_note(issue, closed_via) if system_note
@@ -40,7 +40,7 @@ module Issues
if closed_via.is_a?(MergeRequest)
store_first_mentioned_in_commit_at(issue, closed_via)
- OnboardingProgressService.new(project.namespace).execute(action: :issue_auto_closed)
+ Onboarding::ProgressService.new(project.namespace).execute(action: :issue_auto_closed)
end
delete_milestone_closed_issue_counter_cache(issue.milestone)
@@ -51,11 +51,6 @@ module Issues
private
- # Overridden on EE
- def perform_close(issue)
- issue.close(current_user)
- end
-
def can_close?(issue, skip_authorization: false)
skip_authorization || can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue)
end
diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb
index 6209127bd86..46e4b865dc3 100644
--- a/app/services/issues/export_csv_service.rb
+++ b/app/services/issues/export_csv_service.rb
@@ -5,20 +5,20 @@ module Issues
include Gitlab::Routing.url_helpers
include GitlabRoutingHelper
- def initialize(issuables_relation, project)
- super
+ def initialize(issuables_relation, project, user = nil)
+ super(issuables_relation, project)
@labels = @issuables.labels_hash.transform_values { |labels| labels.sort.join(',').presence }
end
- def email(user)
- Notify.issues_csv_email(user, project, csv_data, csv_builder.status).deliver_now
+ def email(mail_to_user)
+ Notify.issues_csv_email(mail_to_user, project, csv_data, csv_builder.status).deliver_now
end
private
def associations_to_preload
- %i(author assignees timelogs milestone project)
+ [:author, :assignees, :timelogs, :milestone, { project: { namespace: :route } }]
end
def header_to_value_hash
diff --git a/app/services/issues/relative_position_rebalancing_service.rb b/app/services/issues/relative_position_rebalancing_service.rb
index 23bb409f3cd..b5c10430e83 100644
--- a/app/services/issues/relative_position_rebalancing_service.rb
+++ b/app/services/issues/relative_position_rebalancing_service.rb
@@ -16,8 +16,6 @@ module Issues
end
def execute
- return unless Feature.enabled?(:rebalance_issues, root_namespace)
-
# Given can_start_rebalance? and track_new_running_rebalance are not atomic
# it can happen that we end up with more than Rebalancing::State::MAX_NUMBER_OF_CONCURRENT_REBALANCES running.
# Considering the number of allowed Rebalancing::State::MAX_NUMBER_OF_CONCURRENT_REBALANCES is small we should be ok,
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index e003ecacb3f..f4f81e9455a 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -5,7 +5,7 @@ module Issues
def execute(issue, skip_authorization: false)
return issue unless can_reopen?(issue, skip_authorization: skip_authorization)
- if perform_reopen(issue)
+ if issue.reopen
event_service.reopen_issue(issue, current_user)
create_note(issue, 'reopened')
notification_service.async.reopen_issue(issue, current_user)
@@ -22,11 +22,6 @@ module Issues
private
- # Overriden on EE
- def perform_reopen(issue)
- issue.reopen
- end
-
def can_reopen?(issue, skip_authorization: false)
skip_authorization || can?(current_user, :reopen_issue, issue)
end
diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb
index 67163cb8122..a79e5b00232 100644
--- a/app/services/labels/transfer_service.rb
+++ b/app/services/labels/transfer_service.rb
@@ -40,9 +40,9 @@ module Labels
def labels_to_transfer
Label
.from_union([
- group_labels_applied_to_issues,
- group_labels_applied_to_merge_requests
- ])
+ group_labels_applied_to_issues,
+ group_labels_applied_to_merge_requests
+ ])
.reorder(nil)
.distinct
end
diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb
index b4d1b80e5a3..8ef3e307519 100644
--- a/app/services/members/update_service.rb
+++ b/app/services/members/update_service.rb
@@ -7,6 +7,8 @@ module Members
raise Gitlab::Access::AccessDeniedError unless can?(current_user, action_member_permission(permission, member), member)
raise Gitlab::Access::AccessDeniedError if prevent_upgrade_to_owner?(member) || prevent_downgrade_from_owner?(member)
+ return success(member: member) if update_results_in_no_change?(member)
+
old_access_level = member.human_access
old_expiry = member.expires_at
@@ -26,6 +28,13 @@ module Members
private
+ def update_results_in_no_change?(member)
+ return false if params[:expires_at]&.to_date != member.expires_at
+ return false if params[:access_level] != member.access_level
+
+ true
+ end
+
def downgrading_to_guest?
params[:access_level] == Gitlab::Access::GUEST
end
diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb
index 93a0d375b97..9d12eb80eb6 100644
--- a/app/services/merge_requests/after_create_service.rb
+++ b/app/services/merge_requests/after_create_service.rb
@@ -28,7 +28,7 @@ module MergeRequests
merge_request.diffs(include_stats: false).write_cache
merge_request.create_cross_references!(current_user)
- OnboardingProgressService.new(merge_request.target_project.namespace).execute(action: :merge_request_created)
+ Onboarding::ProgressService.new(merge_request.target_project.namespace).execute(action: :merge_request_created)
todo_service.new_merge_request(merge_request, current_user)
merge_request.cache_merge_request_closes_issues!(current_user)
diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb
index dcc4cf4bb1e..64ae33c9b15 100644
--- a/app/services/merge_requests/approval_service.rb
+++ b/app/services/merge_requests/approval_service.rb
@@ -17,19 +17,11 @@ module MergeRequests
# utilizing the `Gitlab::EventStore`.
#
# Workers can subscribe to the `MergeRequests::ApprovedEvent`.
- if Feature.enabled?(:async_after_approval, project)
- Gitlab::EventStore.publish(
- MergeRequests::ApprovedEvent.new(
- data: { current_user_id: current_user.id, merge_request_id: merge_request.id }
- )
+ Gitlab::EventStore.publish(
+ MergeRequests::ApprovedEvent.new(
+ data: { current_user_id: current_user.id, merge_request_id: merge_request.id }
)
- else
- 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)
- end
+ )
success
end
@@ -37,7 +29,7 @@ module MergeRequests
private
def can_be_approved?(merge_request)
- current_user.can?(:approve_merge_request, merge_request)
+ merge_request.can_be_approved_by?(current_user)
end
def save_approval(approval)
@@ -49,29 +41,6 @@ module MergeRequests
def reset_approvals_cache(merge_request)
merge_request.approvals.reset
end
-
- def create_event(merge_request)
- event_service.approve_mr(merge_request, current_user)
- end
-
- def stream_audit_event(merge_request)
- # Defined in EE
- end
-
- def create_approval_note(merge_request)
- SystemNoteService.approve_mr(merge_request, current_user)
- end
-
- def mark_pending_todos_as_done(merge_request)
- todo_service.resolve_todos_for_target(merge_request, current_user)
- end
-
- def execute_approval_hooks(merge_request, current_user)
- # Only one approval is required for a merge request to be approved
- notification_service.async.approve_mr(merge_request, current_user)
-
- execute_hooks(merge_request, 'approved')
- end
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index bda8dc64ac0..6cefd9169f5 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -43,8 +43,6 @@ module MergeRequests
end
def handle_assignees_change(merge_request, old_assignees)
- bulk_update_assignees_state(merge_request, merge_request.assignees - old_assignees)
-
MergeRequests::HandleAssigneesChangeService
.new(project: project, current_user: current_user)
.async_execute(merge_request, old_assignees)
@@ -60,7 +58,6 @@ module MergeRequests
new_reviewers = merge_request.reviewers - old_reviewers
merge_request_activity_counter.track_users_review_requested(users: new_reviewers)
merge_request_activity_counter.track_reviewers_changed_action(user: current_user)
- bulk_update_reviewers_state(merge_request, new_reviewers)
end
def cleanup_environments(merge_request)
@@ -247,46 +244,6 @@ module MergeRequests
Milestones::MergeRequestsCountService.new(milestone).delete_cache
end
-
- def bulk_update_assignees_state(merge_request, new_assignees)
- return unless current_user.mr_attention_requests_enabled?
- return if new_assignees.empty?
-
- assignees_map = merge_request.merge_request_assignees_with(new_assignees).to_h do |assignee|
- state = if assignee.user_id == current_user&.id
- :unreviewed
- else
- merge_request.find_reviewer(assignee.assignee)&.state || :attention_requested
- end
-
- [
- assignee,
- { state: MergeRequestAssignee.states[state], updated_state_by_user_id: current_user.id }
- ]
- end
-
- ::Gitlab::Database::BulkUpdate.execute(%i[state updated_state_by_user_id], assignees_map)
- end
-
- def bulk_update_reviewers_state(merge_request, new_reviewers)
- return unless current_user.mr_attention_requests_enabled?
- return if new_reviewers.empty?
-
- reviewers_map = merge_request.merge_request_reviewers_with(new_reviewers).to_h do |reviewer|
- state = if reviewer.user_id == current_user&.id
- :unreviewed
- else
- merge_request.find_assignee(reviewer.reviewer)&.state || :attention_requested
- end
-
- [
- reviewer,
- { state: MergeRequestReviewer.states[state], updated_state_by_user_id: current_user.id }
- ]
- end
-
- ::Gitlab::Database::BulkUpdate.execute(%i[state updated_state_by_user_id], reviewers_map)
- end
end
end
diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb
index c5640047899..6e1d1b6ad23 100644
--- a/app/services/merge_requests/ff_merge_service.rb
+++ b/app/services/merge_requests/ff_merge_service.rb
@@ -8,26 +8,22 @@ module MergeRequests
# Executed when you do fast-forward merge via GitLab UI
#
class FfMergeService < MergeRequests::MergeService
- private
+ extend ::Gitlab::Utils::Override
- def commit
- ff_merge = repository.ff_merge(current_user,
- source,
- merge_request.target_branch,
- merge_request: merge_request)
+ private
- if merge_request.squash_on_merge?
- merge_request.update_column(:squash_commit_sha, merge_request.in_progress_merge_commit_sha)
- end
+ override :execute_git_merge
+ def execute_git_merge
+ repository.ff_merge(current_user,
+ source,
+ merge_request.target_branch,
+ merge_request: merge_request)
+ end
- ff_merge
- rescue Gitlab::Git::PreReceiveError => e
- Gitlab::ErrorTracking.track_exception(e, pre_receive_message: e.raw_message, merge_request_id: merge_request&.id)
- raise MergeError, e.message
- rescue StandardError => e
- raise MergeError, "Something went wrong during merge: #{e.message}"
- ensure
- merge_request.update_and_mark_in_progress_merge_commit_sha(nil)
+ override :merge_success_data
+ def merge_success_data(commit_id)
+ # There is no merge commit to update, so this is just blank.
+ {}
end
end
end
diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb
index 87cd6544406..51be4690af4 100644
--- a/app/services/merge_requests/handle_assignees_change_service.rb
+++ b/app/services/merge_requests/handle_assignees_change_service.rb
@@ -21,7 +21,7 @@ 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)
- execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks]
+ execute_assignees_hooks(merge_request, old_assignees) if options['execute_hooks']
end
private
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index f51923b7035..6d31a29f5a7 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -92,15 +92,26 @@ module MergeRequests
raise_error(GENERIC_ERROR_MESSAGE)
end
- merge_request.update!(merge_commit_sha: commit_id)
+ update_merge_sha_metadata(commit_id)
+
+ commit_id
ensure
merge_request.update_and_mark_in_progress_merge_commit_sha(nil)
end
+ def update_merge_sha_metadata(commit_id)
+ data_to_update = merge_success_data(commit_id)
+ data_to_update[:squash_commit_sha] = source if merge_request.squash_on_merge?
+
+ merge_request.update!(**data_to_update) if data_to_update.present?
+ end
+
+ def merge_success_data(commit_id)
+ { merge_commit_sha: commit_id }
+ end
+
def try_merge
- repository.merge(current_user, source, merge_request, commit_message).tap do
- merge_request.update_column(:squash_commit_sha, source) if merge_request.squash_on_merge?
- end
+ execute_git_merge
rescue Gitlab::Git::PreReceiveError => e
raise MergeError,
"Something went wrong during merge pre-receive hook. #{e.message}".strip
@@ -109,6 +120,10 @@ module MergeRequests
raise_error(GENERIC_ERROR_MESSAGE)
end
+ def execute_git_merge
+ repository.merge(current_user, source, merge_request, commit_message)
+ end
+
def after_merge
log_info("Post merge started on JID #{merge_jid} with state #{state}")
MergeRequests::PostMergeService.new(project: project, current_user: current_user).execute(merge_request)
diff --git a/app/services/merge_requests/mergeability/detailed_merge_status_service.rb b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb
new file mode 100644
index 00000000000..d25234183fd
--- /dev/null
+++ b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ module Mergeability
+ class DetailedMergeStatusService
+ include ::Gitlab::Utils::StrongMemoize
+
+ def initialize(merge_request:)
+ @merge_request = merge_request
+ end
+
+ def execute
+ return :checking if checking?
+ return :unchecked if unchecked?
+
+ if check_results.success?
+
+ # If everything else is mergeable, but CI is not, the frontend expects two potential states to be returned
+ # See discussion: gitlab.com/gitlab-org/gitlab/-/merge_requests/96778#note_1093063523
+ if check_ci_results.success?
+ :mergeable
+ else
+ ci_check_failure_reason
+ end
+ else
+ check_results.failure_reason
+ end
+ end
+
+ private
+
+ attr_reader :merge_request, :checks, :ci_check
+
+ def checking?
+ merge_request.cannot_be_merged_rechecking? || merge_request.preparing? || merge_request.checking?
+ end
+
+ def unchecked?
+ merge_request.unchecked?
+ end
+
+ def check_results
+ strong_memoize(:check_results) do
+ merge_request.execute_merge_checks(params: { skip_ci_check: true })
+ end
+ end
+
+ def check_ci_results
+ strong_memoize(:check_ci_results) do
+ ::MergeRequests::Mergeability::CheckCiStatusService.new(merge_request: merge_request, params: {}).execute
+ end
+ end
+
+ def ci_check_failure_reason
+ if merge_request.actual_head_pipeline&.running?
+ :ci_still_running
+ else
+ check_ci_results.payload.fetch(:reason)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/mergeability/logger.rb b/app/services/merge_requests/mergeability/logger.rb
new file mode 100644
index 00000000000..8b45d231e03
--- /dev/null
+++ b/app/services/merge_requests/mergeability/logger.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ module Mergeability
+ class Logger
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(merge_request:, destination: Gitlab::AppJsonLogger)
+ @destination = destination
+ @merge_request = merge_request
+ end
+
+ def commit
+ return unless enabled?
+
+ commit_logs
+ end
+
+ def instrument(mergeability_name:)
+ raise ArgumentError, 'block not given' unless block_given?
+
+ return yield unless enabled?
+
+ op_start_db_counters = current_db_counter_payload
+ op_started_at = current_monotonic_time
+
+ result = yield
+
+ observe("mergeability.#{mergeability_name}.duration_s", current_monotonic_time - op_started_at)
+
+ observe_sql_counters(mergeability_name, op_start_db_counters, current_db_counter_payload)
+
+ result
+ end
+
+ private
+
+ attr_reader :destination, :merge_request
+
+ def observe(name, value)
+ return unless enabled?
+
+ observations[name.to_s].push(value)
+ end
+
+ def commit_logs
+ attributes = Gitlab::ApplicationContext.current.merge({
+ mergeability_project_id: merge_request.project.id
+ })
+
+ attributes[:mergeability_merge_request_id] = merge_request.id
+ attributes.merge!(observations_hash)
+ attributes.compact!
+ attributes.stringify_keys!
+
+ destination.info(attributes)
+ end
+
+ def observations_hash
+ transformed = observations.transform_values do |values|
+ next if values.empty?
+
+ {
+ 'values' => values
+ }
+ end.compact
+
+ transformed.each_with_object({}) do |key, hash|
+ key[1].each { |k, v| hash["#{key[0]}.#{k}"] = v }
+ end
+ end
+
+ def observations
+ strong_memoize(:observations) do
+ Hash.new { |hash, key| hash[key] = [] }
+ end
+ end
+
+ def observe_sql_counters(name, start_db_counters, end_db_counters)
+ end_db_counters.each do |key, value|
+ result = value - start_db_counters.fetch(key, 0)
+ next if result == 0
+
+ observe("mergeability.#{name}.#{key}", result)
+ end
+ end
+
+ def current_db_counter_payload
+ ::Gitlab::Metrics::Subscribers::ActiveRecord.db_counter_payload
+ end
+
+ def enabled?
+ strong_memoize(:enabled) do
+ ::Feature.enabled?(:mergeability_checks_logger, merge_request.project)
+ end
+ end
+
+ def current_monotonic_time
+ ::Gitlab::Metrics::System.monotonic_time
+ 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 68f842b3322..7f205c8dd6c 100644
--- a/app/services/merge_requests/mergeability/run_checks_service.rb
+++ b/app/services/merge_requests/mergeability/run_checks_service.rb
@@ -15,12 +15,17 @@ module MergeRequests
next if check.skip?
- check_result = run_check(check)
+ check_result = logger.instrument(mergeability_name: check_class.to_s.demodulize.underscore) do
+ run_check(check)
+ end
+
result_hash << check_result
break result_hash if check_result.failed?
end
+ logger.commit
+
self
end
@@ -57,6 +62,12 @@ module MergeRequests
Gitlab::MergeRequests::Mergeability::ResultsStore.new(merge_request: merge_request)
end
end
+
+ def logger
+ strong_memoize(:logger) do
+ MergeRequests::Mergeability::Logger.new(merge_request: merge_request)
+ end
+ end
end
end
end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 5205d34baae..533d0052fb8 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -234,6 +234,7 @@ module MergeRequests
end
# Add comment about pushing new commits to merge requests and send nofitication emails
+ #
def notify_about_push(merge_request)
return unless @commits.present?
diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb
index a6b0235c525..a13db52e34b 100644
--- a/app/services/merge_requests/update_assignees_service.rb
+++ b/app/services/merge_requests/update_assignees_service.rb
@@ -20,8 +20,6 @@ module MergeRequests
attrs = update_attrs.merge(assignee_ids: new_ids)
merge_request.update!(**attrs)
- bulk_update_assignees_state(merge_request, merge_request.assignees - old_assignees)
-
# Defer the more expensive operations (handle_assignee_changes) to the background
MergeRequests::HandleAssigneesChangeService
.new(project: project, current_user: current_user)
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 0902b5195a1..6d518edc88f 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -179,18 +179,16 @@ module MergeRequests
old_title_draft = MergeRequest.draft?(old_title)
new_title_draft = MergeRequest.draft?(new_title)
+ # notify the draft status changed. Added/removed message is handled in the
+ # email template itself, see `change_in_merge_request_draft_status_email` template.
+ notify_draft_status_changed(merge_request) if old_title_draft || new_title_draft
+
if !old_title_draft && new_title_draft
# Marked as Draft
- #
- merge_request_activity_counter
- .track_marked_as_draft_action(user: current_user)
+ merge_request_activity_counter.track_marked_as_draft_action(user: current_user)
elsif old_title_draft && !new_title_draft
# Unmarked as Draft
- #
- notify_draft_status_changed(merge_request)
-
- merge_request_activity_counter
- .track_unmarked_as_draft_action(user: current_user)
+ merge_request_activity_counter.track_unmarked_as_draft_action(user: current_user)
end
end
diff --git a/app/services/milestones/transfer_service.rb b/app/services/milestones/transfer_service.rb
index b9bd259ca8b..bbf6920f83b 100644
--- a/app/services/milestones/transfer_service.rb
+++ b/app/services/milestones/transfer_service.rb
@@ -35,10 +35,7 @@ module Milestones
# rubocop: disable CodeReuse/ActiveRecord
def milestones_to_transfer
- Milestone.from_union([
- group_milestones_applied_to_issues,
- group_milestones_applied_to_merge_requests
- ])
+ Milestone.from_union([group_milestones_applied_to_issues, group_milestones_applied_to_merge_requests])
.reorder(nil)
.distinct
end
diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb
index c139b2e11dd..1ce7e4cae16 100644
--- a/app/services/namespaces/in_product_marketing_emails_service.rb
+++ b/app/services/namespaces/in_product_marketing_emails_service.rb
@@ -89,7 +89,7 @@ module Namespaces
end
def groups_for_track
- onboarding_progress_scope = OnboardingProgress
+ onboarding_progress_scope = Onboarding::Progress
.completed_actions_with_latest_in_range(completed_actions, range)
.incomplete_actions(incomplete_actions)
diff --git a/app/services/notification_recipients/builder/base.rb b/app/services/notification_recipients/builder/base.rb
index 0a7f25f1af3..3fabec29c0d 100644
--- a/app/services/notification_recipients/builder/base.rb
+++ b/app/services/notification_recipients/builder/base.rb
@@ -183,58 +183,6 @@ module NotificationRecipients
add_recipients(target.subscribers(project), :subscription, NotificationReason::SUBSCRIBED)
end
- # rubocop: disable CodeReuse/ActiveRecord
- def user_ids_notifiable_on(resource, notification_level = nil)
- return [] unless resource
-
- scope = resource.notification_settings
-
- if notification_level
- scope = scope.where(level: NotificationSetting.levels[notification_level])
- end
-
- scope.pluck(:user_id)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # Build a list of user_ids based on project notification settings
- def select_project_members_ids(global_setting, user_ids_global_level_watch)
- user_ids = user_ids_notifiable_on(project, :watch)
-
- # If project setting is global, add to watch list if global setting is watch
- user_ids + (global_setting & user_ids_global_level_watch)
- end
-
- # Build a list of user_ids based on group notification settings
- def select_group_members_ids(group, project_members, global_setting, user_ids_global_level_watch)
- uids = user_ids_notifiable_on(group, :watch)
-
- # Group setting is global, add to user_ids list if global setting is watch
- uids + (global_setting & user_ids_global_level_watch) - project_members
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def user_ids_with_global_level_watch(ids)
- settings_with_global_level_of(:watch, ids).pluck(:user_id)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
- def user_ids_with_global_level_custom(ids, action)
- settings_with_global_level_of(:custom, ids).pluck(:user_id)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
- def settings_with_global_level_of(level, ids)
- NotificationSetting.where(
- user_id: ids,
- source_type: nil,
- level: NotificationSetting.levels[level]
- )
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def add_labels_subscribers(labels: nil)
return unless target.respond_to? :labels
diff --git a/app/services/onboarding/progress_service.rb b/app/services/onboarding/progress_service.rb
new file mode 100644
index 00000000000..66f7f2bc33d
--- /dev/null
+++ b/app/services/onboarding/progress_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Onboarding
+ class ProgressService
+ class Async
+ attr_reader :namespace_id
+
+ def initialize(namespace_id)
+ @namespace_id = namespace_id
+ end
+
+ def execute(action:)
+ return unless Onboarding::Progress.not_completed?(namespace_id, action)
+
+ Namespaces::OnboardingProgressWorker.perform_async(namespace_id, action)
+ end
+ end
+
+ def self.async(namespace_id)
+ Async.new(namespace_id)
+ end
+
+ def initialize(namespace)
+ @namespace = namespace&.root_ancestor
+ end
+
+ def execute(action:)
+ return unless @namespace
+
+ Onboarding::Progress.register(@namespace, action)
+ end
+ end
+end
diff --git a/app/services/onboarding_progress_service.rb b/app/services/onboarding_progress_service.rb
deleted file mode 100644
index 6d44c0a61ea..00000000000
--- a/app/services/onboarding_progress_service.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-class OnboardingProgressService
- class Async
- attr_reader :namespace_id
-
- def initialize(namespace_id)
- @namespace_id = namespace_id
- end
-
- def execute(action:)
- return unless OnboardingProgress.not_completed?(namespace_id, action)
-
- Namespaces::OnboardingProgressWorker.perform_async(namespace_id, action)
- end
- end
-
- def self.async(namespace_id)
- Async.new(namespace_id)
- end
-
- def initialize(namespace)
- @namespace = namespace&.root_ancestor
- end
-
- def execute(action:)
- return unless @namespace
-
- OnboardingProgress.register(@namespace, action)
- end
-end
diff --git a/app/services/packages/conan/search_service.rb b/app/services/packages/conan/search_service.rb
index 31ee9bea084..df22a895c00 100644
--- a/app/services/packages/conan/search_service.rb
+++ b/app/services/packages/conan/search_service.rb
@@ -44,7 +44,7 @@ module Packages
name, version, username, _ = query.split(%r{[@/]})
full_path = Packages::Conan::Metadatum.full_path_from(package_username: username)
project = Project.find_by_full_path(full_path)
- return unless Ability.allowed?(current_user, :read_package, project)
+ return unless Ability.allowed?(current_user, :read_package, project&.packages_policy_subject)
result = project.packages.with_name(name).with_version(version).order_created.last
[result&.conan_recipe].compact
diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb
index 7db27f9234d..9b313202400 100644
--- a/app/services/packages/debian/generate_distribution_service.rb
+++ b/app/services/packages/debian/generate_distribution_service.rb
@@ -220,6 +220,7 @@ module Packages
valid_until_field,
rfc822_field('NotAutomatic', !@distribution.automatic, !@distribution.automatic),
rfc822_field('ButAutomaticUpgrades', @distribution.automatic_upgrades, !@distribution.automatic && @distribution.automatic_upgrades),
+ rfc822_field('Acquire-By-Hash', 'yes'),
rfc822_field('Architectures', @distribution.architectures.map { |architecture| architecture.name }.sort.join(' ')),
rfc822_field('Components', @distribution.components.map { |component| component.name }.sort.join(' ')),
rfc822_field('Description', @distribution.description)
diff --git a/app/services/packages/debian/process_changes_service.rb b/app/services/packages/debian/process_changes_service.rb
index b6e81012656..a29cbd3f65f 100644
--- a/app/services/packages/debian/process_changes_service.rb
+++ b/app/services/packages/debian/process_changes_service.rb
@@ -42,22 +42,30 @@ module Packages
def update_files_metadata
files.each do |filename, entry|
- entry.package_file.package = package
-
file_metadata = ::Packages::Debian::ExtractMetadataService.new(entry.package_file).execute
+ ::Packages::UpdatePackageFileService.new(entry.package_file, package_id: package.id)
+ .execute
+
+ # Force reload from database, as package has changed
+ entry.package_file.reload_package
+
entry.package_file.debian_file_metadatum.update!(
file_type: file_metadata[:file_type],
component: files[filename].component,
architecture: file_metadata[:architecture],
fields: file_metadata[:fields]
)
- entry.package_file.save!
end
end
def update_changes_metadata
- package_file.update!(package: package)
+ ::Packages::UpdatePackageFileService.new(package_file, package_id: package.id)
+ .execute
+
+ # Force reload from database, as package has changed
+ package_file.reload_package
+
package_file.debian_file_metadatum.update!(
file_type: metadata[:file_type],
fields: metadata[:fields]
diff --git a/app/services/packages/rpm/repository_metadata/base_builder.rb b/app/services/packages/rpm/repository_metadata/base_builder.rb
new file mode 100644
index 00000000000..9d76336d764
--- /dev/null
+++ b/app/services/packages/rpm/repository_metadata/base_builder.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+module Packages
+ module Rpm
+ module RepositoryMetadata
+ class BaseBuilder
+ def execute
+ build_empty_structure
+ end
+
+ private
+
+ def build_empty_structure
+ Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
+ xml.public_send(self.class::ROOT_TAG, self.class::ROOT_ATTRIBUTES) # rubocop:disable GitlabSecurity/PublicSend
+ end.to_xml
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rpm/repository_metadata/build_filelist_xml.rb b/app/services/packages/rpm/repository_metadata/build_filelist_xml.rb
new file mode 100644
index 00000000000..01fb36f4b91
--- /dev/null
+++ b/app/services/packages/rpm/repository_metadata/build_filelist_xml.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+module Packages
+ module Rpm
+ module RepositoryMetadata
+ class BuildFilelistXml < ::Packages::Rpm::RepositoryMetadata::BaseBuilder
+ ROOT_TAG = 'filelists'
+ ROOT_ATTRIBUTES = {
+ xmlns: 'http://linux.duke.edu/metadata/filelists',
+ packages: '0'
+ }.freeze
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rpm/repository_metadata/build_other_xml.rb b/app/services/packages/rpm/repository_metadata/build_other_xml.rb
new file mode 100644
index 00000000000..4bf61c901a3
--- /dev/null
+++ b/app/services/packages/rpm/repository_metadata/build_other_xml.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+module Packages
+ module Rpm
+ module RepositoryMetadata
+ class BuildOtherXml < ::Packages::Rpm::RepositoryMetadata::BaseBuilder
+ ROOT_TAG = 'otherdata'
+ ROOT_ATTRIBUTES = {
+ xmlns: 'http://linux.duke.edu/metadata/other',
+ packages: '0'
+ }.freeze
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rpm/repository_metadata/build_primary_xml.rb b/app/services/packages/rpm/repository_metadata/build_primary_xml.rb
new file mode 100644
index 00000000000..affb41677c2
--- /dev/null
+++ b/app/services/packages/rpm/repository_metadata/build_primary_xml.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+module Packages
+ module Rpm
+ module RepositoryMetadata
+ class BuildPrimaryXml < ::Packages::Rpm::RepositoryMetadata::BaseBuilder
+ ROOT_TAG = 'metadata'
+ ROOT_ATTRIBUTES = {
+ xmlns: 'http://linux.duke.edu/metadata/common',
+ 'xmlns:rpm': 'http://linux.duke.edu/metadata/rpm',
+ packages: '0'
+ }.freeze
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb b/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb
new file mode 100644
index 00000000000..c6cfd77815d
--- /dev/null
+++ b/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+module Packages
+ module Rpm
+ module RepositoryMetadata
+ class BuildRepomdXml
+ attr_reader :data
+
+ ROOT_ATTRIBUTES = {
+ xmlns: 'http://linux.duke.edu/metadata/repo',
+ 'xmlns:rpm': 'http://linux.duke.edu/metadata/rpm'
+ }.freeze
+
+ # Expected `data` structure
+ #
+ # data = {
+ # filelists: {
+ # checksum: { type: "sha256", value: "123" },
+ # location: { href: "repodata/123-filelists.xml.gz" },
+ # ...
+ # },
+ # ...
+ # }
+ def initialize(data)
+ @data = data
+ end
+
+ def execute
+ build_repomd
+ end
+
+ private
+
+ def build_repomd
+ Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
+ xml.repomd(ROOT_ATTRIBUTES) do
+ xml.revision Time.now.to_i
+ build_data_info(xml)
+ end
+ end.to_xml
+ end
+
+ def build_data_info(xml)
+ data.each do |filename, info|
+ xml.data(type: filename) do
+ build_file_info(info, xml)
+ end
+ end
+ end
+
+ def build_file_info(info, xml)
+ info.each do |key, attributes|
+ value = attributes.delete(:value)
+ xml.public_send(key, value, attributes) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rubygems/dependency_resolver_service.rb b/app/services/packages/rubygems/dependency_resolver_service.rb
index c44b26e2b92..839a7683632 100644
--- a/app/services/packages/rubygems/dependency_resolver_service.rb
+++ b/app/services/packages/rubygems/dependency_resolver_service.rb
@@ -8,7 +8,10 @@ module Packages
DEFAULT_PLATFORM = 'ruby'
def execute
- return ServiceResponse.error(message: "forbidden", http_status: :forbidden) unless Ability.allowed?(current_user, :read_package, project)
+ unless Ability.allowed?(current_user, :read_package, project&.packages_policy_subject)
+ return ServiceResponse.error(message: "forbidden", http_status: :forbidden)
+ end
+
return ServiceResponse.error(message: "#{gem_name} not found", http_status: :not_found) if packages.empty?
payload = packages.map do |package|
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
index 15c978e6763..c376b4036f8 100644
--- a/app/services/post_receive_service.rb
+++ b/app/services/post_receive_service.rb
@@ -101,7 +101,7 @@ class PostReceiveService
def record_onboarding_progress
return unless project
- OnboardingProgressService.new(project.namespace).execute(action: :git_write)
+ Onboarding::ProgressService.new(project.namespace).execute(action: :git_write)
end
end
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
index c21a61bcb52..9403c7bcfed 100644
--- a/app/services/projects/alerting/notify_service.rb
+++ b/app/services/projects/alerting/notify_service.rb
@@ -2,14 +2,13 @@
module Projects
module Alerting
- class NotifyService
+ class NotifyService < ::BaseProjectService
extend ::Gitlab::Utils::Override
include ::AlertManagement::AlertProcessing
include ::AlertManagement::Responses
- def initialize(project, payload)
- @project = project
- @payload = payload
+ def initialize(project, params)
+ super(project: project, params: params.to_h)
end
def execute(token, integration = nil)
@@ -29,15 +28,11 @@ module Projects
private
- attr_reader :project, :payload, :integration
+ attr_reader :integration
+ alias_method :payload, :params
def valid_payload_size?
- Gitlab::Utils::DeepSize.new(payload.to_h).valid?
- end
-
- override :alert_source
- def alert_source
- super || integration&.name || 'Generic Alert Endpoint'
+ Gitlab::Utils::DeepSize.new(params).valid?
end
def active_integration?
diff --git a/app/services/projects/blame_service.rb b/app/services/projects/blame_service.rb
index b324ea27360..57b913b04e6 100644
--- a/app/services/projects/blame_service.rb
+++ b/app/services/projects/blame_service.rb
@@ -10,6 +10,7 @@ module Projects
@blob = blob
@commit = commit
@page = extract_page(params)
+ @pagination_enabled = pagination_state(params)
end
attr_reader :page
@@ -19,7 +20,7 @@ module Projects
end
def pagination
- return unless pagination_enabled?
+ return unless pagination_enabled
Kaminari.paginate_array([], total_count: blob_lines_count, limit: per_page)
.tap { |pagination| pagination.max_paginates_per(per_page) }
@@ -28,10 +29,10 @@ module Projects
private
- attr_reader :blob, :commit
+ attr_reader :blob, :commit, :pagination_enabled
def blame_range
- return unless pagination_enabled?
+ return unless pagination_enabled
first_line = (page - 1) * per_page + 1
last_line = (first_line + per_page).to_i - 1
@@ -51,6 +52,12 @@ module Projects
PER_PAGE
end
+ def pagination_state(params)
+ return false if Gitlab::Utils.to_boolean(params[:no_pagination], default: false)
+
+ Feature.enabled?(:blame_page_pagination, commit.project)
+ end
+
def overlimit?(page)
page * per_page >= blob_lines_count + per_page
end
@@ -58,9 +65,5 @@ module Projects
def blob_lines_count
@blob_lines_count ||= blob.data.lines.count
end
-
- def pagination_enabled?
- Feature.enabled?(:blame_page_pagination, commit.project)
- end
end
end
diff --git a/app/services/projects/container_repository/base_container_repository_service.rb b/app/services/projects/container_repository/base_container_repository_service.rb
new file mode 100644
index 00000000000..d7539737e78
--- /dev/null
+++ b/app/services/projects/container_repository/base_container_repository_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Projects
+ module ContainerRepository
+ class BaseContainerRepositoryService < ::BaseContainerService
+ include ::Gitlab::Utils::StrongMemoize
+
+ alias_method :container_repository, :container
+
+ def initialize(container_repository:, current_user: nil, params: {})
+ super(container: container_repository, current_user: current_user, params: params)
+ end
+
+ delegate :project, to: :container_repository
+ end
+ end
+end
diff --git a/app/services/projects/container_repository/cleanup_tags_base_service.rb b/app/services/projects/container_repository/cleanup_tags_base_service.rb
new file mode 100644
index 00000000000..8ea4ae4830a
--- /dev/null
+++ b/app/services/projects/container_repository/cleanup_tags_base_service.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+module Projects
+ module ContainerRepository
+ class CleanupTagsBaseService < BaseContainerRepositoryService
+ private
+
+ def filter_out_latest!(tags)
+ tags.reject!(&:latest?)
+ end
+
+ def filter_by_name!(tags)
+ regex_delete = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_delete || name_regex}\\z")
+ regex_retain = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_keep}\\z")
+
+ tags.select! do |tag|
+ # regex_retain will override any overlapping matches by regex_delete
+ regex_delete.match?(tag.name) && !regex_retain.match?(tag.name)
+ end
+ end
+
+ # Should return [tags_to_delete, tags_to_keep]
+ def partition_by_keep_n(tags)
+ return [tags, []] unless keep_n
+
+ tags = order_by_date_desc(tags)
+
+ tags.partition.with_index { |_, index| index >= keep_n_as_integer }
+ end
+
+ # Should return [tags_to_delete, tags_to_keep]
+ def partition_by_older_than(tags)
+ return [tags, []] unless older_than
+
+ older_than_timestamp = older_than_in_seconds.ago
+
+ tags.partition do |tag|
+ timestamp = pushed_at(tag)
+
+ timestamp && timestamp < older_than_timestamp
+ end
+ end
+
+ def order_by_date_desc(tags)
+ now = DateTime.current
+ tags.sort_by! { |tag| pushed_at(tag) || now }
+ .reverse!
+ end
+
+ def delete_tags(tags)
+ return success(deleted: []) unless tags.any?
+
+ service = Projects::ContainerRepository::DeleteTagsService.new(
+ project,
+ current_user,
+ tags: tags.map(&:name),
+ container_expiration_policy: container_expiration_policy
+ )
+
+ service.execute(container_repository)
+ end
+
+ def can_destroy?
+ return true if container_expiration_policy
+
+ can?(current_user, :destroy_container_image, project)
+ end
+
+ def valid_regex?
+ %w[name_regex_delete name_regex name_regex_keep].each do |param_name|
+ regex = params[param_name]
+ ::Gitlab::UntrustedRegexp.new(regex) unless regex.blank?
+ end
+ true
+ rescue RegexpError => e
+ ::Gitlab::ErrorTracking.log_exception(e, project_id: project.id)
+ false
+ end
+
+ def older_than
+ params['older_than']
+ end
+
+ def name_regex_delete
+ params['name_regex_delete']
+ end
+
+ def name_regex
+ params['name_regex']
+ end
+
+ def name_regex_keep
+ params['name_regex_keep']
+ end
+
+ def container_expiration_policy
+ params['container_expiration_policy']
+ end
+
+ def keep_n
+ params['keep_n']
+ end
+
+ def project
+ container_repository.project
+ end
+
+ def keep_n_as_integer
+ keep_n.to_i
+ end
+
+ def older_than_in_seconds
+ strong_memoize(:older_than_in_seconds) do
+ ChronicDuration.parse(older_than).seconds
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
index 0a8e8e72766..285c3e252ef 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -2,39 +2,33 @@
module Projects
module ContainerRepository
- class CleanupTagsService
- include BaseServiceUtility
- include ::Gitlab::Utils::StrongMemoize
+ class CleanupTagsService < CleanupTagsBaseService
+ def initialize(container_repository:, current_user: nil, params: {})
+ super
- def initialize(container_repository, user = nil, params = {})
- @container_repository = container_repository
- @current_user = user
@params = params.dup
-
- @project = container_repository.project
- @tags = container_repository.tags
- tags_size = @tags.size
- @counts = {
- original_size: tags_size,
- cached_tags_count: 0
- }
+ @counts = { cached_tags_count: 0 }
end
def execute
return error('access denied') unless can_destroy?
return error('invalid regex') unless valid_regex?
- filter_out_latest
- filter_by_name
+ tags = container_repository.tags
+ @counts[:original_size] = tags.size
+
+ filter_out_latest!(tags)
+ filter_by_name!(tags)
+
+ tags = truncate(tags)
+ populate_from_cache(tags)
- truncate
- populate_from_cache
+ tags = filter_keep_n(tags)
+ tags = filter_by_older_than(tags)
- filter_keep_n
- filter_by_older_than
+ @counts[:before_delete_size] = tags.size
- delete_tags.merge(@counts).tap do |result|
- result[:before_delete_size] = @tags.size
+ delete_tags(tags).merge(@counts).tap do |result|
result[:deleted_size] = result[:deleted]&.size
result[:status] = :error if @counts[:before_truncate_size] != @counts[:after_truncate_size]
@@ -43,94 +37,45 @@ module Projects
private
- def delete_tags
- return success(deleted: []) unless @tags.any?
-
- service = Projects::ContainerRepository::DeleteTagsService.new(
- @project,
- @current_user,
- tags: @tags.map(&:name),
- container_expiration_policy: container_expiration_policy
- )
-
- service.execute(@container_repository)
- end
-
- def filter_out_latest
- @tags.reject!(&:latest?)
- end
-
- def order_by_date
- now = DateTime.current
- @tags.sort_by! { |tag| tag.created_at || now }
- .reverse!
- end
+ def filter_keep_n(tags)
+ tags, tags_to_keep = partition_by_keep_n(tags)
- def filter_by_name
- regex_delete = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_delete || name_regex}\\z")
- regex_retain = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_keep}\\z")
-
- @tags.select! do |tag|
- # regex_retain will override any overlapping matches by regex_delete
- regex_delete.match?(tag.name) && !regex_retain.match?(tag.name)
- end
- end
-
- def filter_keep_n
- return unless keep_n
+ cache_tags(tags_to_keep)
- order_by_date
- cache_tags(@tags.first(keep_n_as_integer))
- @tags = @tags.drop(keep_n_as_integer)
+ tags
end
- def filter_by_older_than
- return unless older_than
-
- older_than_timestamp = older_than_in_seconds.ago
-
- @tags, tags_to_keep = @tags.partition do |tag|
- tag.created_at && tag.created_at < older_than_timestamp
- end
+ def filter_by_older_than(tags)
+ tags, tags_to_keep = partition_by_older_than(tags)
cache_tags(tags_to_keep)
- end
- def can_destroy?
- return true if container_expiration_policy
-
- can?(@current_user, :destroy_container_image, @project)
+ tags
end
- def valid_regex?
- %w(name_regex_delete name_regex name_regex_keep).each do |param_name|
- regex = @params[param_name]
- ::Gitlab::UntrustedRegexp.new(regex) unless regex.blank?
- end
- true
- rescue RegexpError => e
- ::Gitlab::ErrorTracking.log_exception(e, project_id: @project.id)
- false
+ def pushed_at(tag)
+ tag.created_at
end
- def truncate
- @counts[:before_truncate_size] = @tags.size
- @counts[:after_truncate_size] = @tags.size
+ def truncate(tags)
+ @counts[:before_truncate_size] = tags.size
+ @counts[:after_truncate_size] = tags.size
- return if max_list_size == 0
+ return tags if max_list_size == 0
# truncate the list to make sure that after the #filter_keep_n
# execution, the resulting list will be max_list_size
truncated_size = max_list_size + keep_n_as_integer
- return if @tags.size <= truncated_size
+ return tags if tags.size <= truncated_size
- @tags = @tags.sample(truncated_size)
- @counts[:after_truncate_size] = @tags.size
+ tags = tags.sample(truncated_size)
+ @counts[:after_truncate_size] = tags.size
+ tags
end
- def populate_from_cache
- @counts[:cached_tags_count] = cache.populate(@tags) if caching_enabled?
+ def populate_from_cache(tags)
+ @counts[:cached_tags_count] = cache.populate(tags) if caching_enabled?
end
def cache_tags(tags)
@@ -139,7 +84,7 @@ module Projects
def cache
strong_memoize(:cache) do
- ::Gitlab::ContainerRepository::Tags::Cache.new(@container_repository)
+ ::Gitlab::ContainerRepository::Tags::Cache.new(container_repository)
end
end
@@ -153,40 +98,6 @@ module Projects
def max_list_size
::Gitlab::CurrentSettings.current_application_settings.container_registry_cleanup_tags_service_max_list_size.to_i
end
-
- def keep_n
- @params['keep_n']
- end
-
- def keep_n_as_integer
- keep_n.to_i
- end
-
- def older_than_in_seconds
- strong_memoize(:older_than_in_seconds) do
- ChronicDuration.parse(older_than).seconds
- end
- end
-
- def older_than
- @params['older_than']
- end
-
- def name_regex_delete
- @params['name_regex_delete']
- end
-
- def name_regex
- @params['name_regex']
- end
-
- def name_regex_keep
- @params['name_regex_keep']
- end
-
- def container_expiration_policy
- @params['container_expiration_policy']
- end
end
end
end
diff --git a/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb
new file mode 100644
index 00000000000..81bb94c867a
--- /dev/null
+++ b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module Projects
+ module ContainerRepository
+ module Gitlab
+ class CleanupTagsService < CleanupTagsBaseService
+ include ::Projects::ContainerRepository::Gitlab::Timeoutable
+
+ TAGS_PAGE_SIZE = 1000
+
+ def initialize(container_repository:, current_user: nil, params: {})
+ super
+ @params = params.dup
+ end
+
+ def execute
+ return error('access denied') unless can_destroy?
+ return error('invalid regex') unless valid_regex?
+
+ with_timeout do |start_time, result|
+ container_repository.each_tags_page(page_size: TAGS_PAGE_SIZE) do |tags|
+ execute_for_tags(tags, result)
+
+ raise TimeoutError if timeout?(start_time)
+ end
+ end
+ end
+
+ private
+
+ def execute_for_tags(tags, overall_result)
+ original_size = tags.size
+
+ filter_out_latest!(tags)
+ filter_by_name!(tags)
+
+ tags = filter_by_keep_n(tags)
+ tags = filter_by_older_than(tags)
+
+ overall_result[:before_delete_size] += tags.size
+ overall_result[:original_size] += original_size
+
+ result = delete_tags(tags)
+
+ overall_result[:deleted_size] += result[:deleted]&.size
+ overall_result[:deleted] += result[:deleted]
+ overall_result[:status] = result[:status] unless overall_result[:status] == :error
+ end
+
+ def with_timeout
+ result = {
+ original_size: 0,
+ before_delete_size: 0,
+ deleted_size: 0,
+ deleted: []
+ }
+
+ yield Time.zone.now, result
+
+ result
+ rescue TimeoutError
+ result[:status] = :error
+
+ result
+ end
+
+ def filter_by_keep_n(tags)
+ partition_by_keep_n(tags).first
+ end
+
+ def filter_by_older_than(tags)
+ partition_by_older_than(tags).first
+ end
+
+ def pushed_at(tag)
+ tag.updated_at || tag.created_at
+ end
+ end
+ end
+ end
+end
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 81cef554dec..530cf87c338 100644
--- a/app/services/projects/container_repository/gitlab/delete_tags_service.rb
+++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb
@@ -6,10 +6,7 @@ module Projects
class DeleteTagsService
include BaseServiceUtility
include ::Gitlab::Utils::StrongMemoize
-
- DISABLED_TIMEOUTS = [nil, 0].freeze
-
- TimeoutError = Class.new(StandardError)
+ include ::Projects::ContainerRepository::Gitlab::Timeoutable
def initialize(container_repository, tag_names)
@container_repository = container_repository
@@ -44,16 +41,6 @@ module Projects
@deleted_tags.any? ? success(deleted: @deleted_tags) : error('could not delete tags')
end
-
- def timeout?(start_time)
- return false if service_timeout.in?(DISABLED_TIMEOUTS)
-
- (Time.zone.now - start_time) > service_timeout
- end
-
- def service_timeout
- ::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout
- end
end
end
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 6381ee67ce7..c72f9b4b602 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -96,7 +96,7 @@ module Projects
log_info("#{current_user.name} created a new project \"#{@project.full_name}\"")
if @project.import?
- experiment(:combined_registration, user: current_user).track(:import_project)
+ Gitlab::Tracking.event(self.class.name, 'import_project', user: current_user)
else
# Skip writing the config for project imports/forks because it
# will always fail since the Git directory doesn't exist until
@@ -158,14 +158,25 @@ module Projects
priority: UserProjectAccessChangedService::LOW_PRIORITY
)
else
- @project.add_owner(@project.namespace.owner, current_user: current_user)
+ owner_user = @project.namespace.owner
+ owner_member = @project.add_owner(owner_user, current_user: current_user)
+
+ # There is a possibility that the sidekiq job to refresh the authorizations of the owner_user in this project
+ # isn't picked up (or finished) by the time the user is redirected to the newly created project's page.
+ # If that happens, the user will hit a 404. To avoid that scenario, we manually create a `project_authorizations` record for the user here.
+ if owner_member.persisted?
+ owner_user.project_authorizations.safe_find_or_create_by(
+ project: @project,
+ access_level: ProjectMember::OWNER
+ )
+ end
# During the process of adding a project owner, a check on permissions is made on the user which caches
# the max member access for that user on this project.
# Since that is `0` before the member is created - and we are still inside the request
# cycle when we need to do other operations that might check those permissions (e.g. write a commit)
# we need to purge that cache so that the updated permissions is fetched instead of using the outdated cached value of 0
# from before member creation
- @project.team.purge_member_access_cache_for_user_id(@project.namespace.owner.id)
+ @project.team.purge_member_access_cache_for_user_id(owner_user.id)
end
end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 06a44b07f9f..f1525ed9763 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -67,9 +67,9 @@ module Projects
end
def remove_snippets
- # We're setting the hard_delete param because we dont need to perform the access checks within the service since
+ # We're setting the skip_authorization 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)
+ response = ::Snippets::BulkDestroyService.new(current_user, project.snippets).execute(skip_authorization: true)
if response.error?
log_error("Snippet deletion failed on #{project.full_path} with the following message: #{response.message}")
@@ -134,6 +134,8 @@ module Projects
destroy_ci_records!
destroy_mr_diff_relations!
+ destroy_merge_request_diffs! if ::Feature.enabled?(:extract_mr_diff_deletions)
+
# Rails attempts to load all related records into memory before
# destroying: https://github.com/rails/rails/issues/22510
# This ensures we delete records in batches.
@@ -158,10 +160,9 @@ module Projects
#
# rubocop: disable CodeReuse/ActiveRecord
def destroy_mr_diff_relations!
- mr_batch_size = 100
delete_batch_size = 1000
- project.merge_requests.each_batch(column: :iid, of: mr_batch_size) do |relation_ids|
+ project.merge_requests.each_batch(column: :iid, of: BATCH_SIZE) do |relation_ids|
[MergeRequestDiffCommit, MergeRequestDiffFile].each do |model|
loop do
inner_query = model
@@ -180,6 +181,23 @@ module Projects
end
# rubocop: enable CodeReuse/ActiveRecord
+ # rubocop: disable CodeReuse/ActiveRecord
+ def destroy_merge_request_diffs!
+ delete_batch_size = 1000
+
+ project.merge_requests.each_batch(column: :iid, of: BATCH_SIZE) do |relation|
+ loop do
+ deleted_rows = MergeRequestDiff
+ .where(merge_request: relation)
+ .limit(delete_batch_size)
+ .delete_all
+
+ break if deleted_rows == 0
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def destroy_ci_records!
# Make sure to destroy this first just in case the project is undergoing stats refresh.
# This is to avoid logging the artifact deletion in Ci::JobArtifacts::DestroyBatchService.
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index 6265a74fad2..9f260345937 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -3,9 +3,8 @@
module Projects
module Prometheus
module Alerts
- class NotifyService
+ class NotifyService < ::BaseProjectService
include Gitlab::Utils::StrongMemoize
- include ::IncidentManagement::Settings
include ::AlertManagement::Responses
# This set of keys identifies a payload as a valid Prometheus
@@ -26,14 +25,13 @@ module Projects
# https://gitlab.com/gitlab-com/gl-infra/production/-/issues/6086
PROCESS_MAX_ALERTS = 100
- def initialize(project, payload)
- @project = project
- @payload = payload
+ def initialize(project, params)
+ super(project: project, params: params.to_h)
end
def execute(token, integration = nil)
return bad_request unless valid_payload_size?
- return unprocessable_entity unless self.class.processable?(payload)
+ return unprocessable_entity unless self.class.processable?(params)
return unauthorized unless valid_alert_manager_token?(token, integration)
truncate_alerts! if max_alerts_exceeded?
@@ -53,10 +51,8 @@ module Projects
private
- attr_reader :project, :payload
-
def valid_payload_size?
- Gitlab::Utils::DeepSize.new(payload.to_h).valid?
+ Gitlab::Utils::DeepSize.new(params).valid?
end
def max_alerts_exceeded?
@@ -75,11 +71,11 @@ module Projects
}
)
- payload['alerts'] = alerts.first(PROCESS_MAX_ALERTS)
+ params['alerts'] = alerts.first(PROCESS_MAX_ALERTS)
end
def alerts
- payload['alerts']
+ params['alerts']
end
def valid_alert_manager_token?(token, integration)
@@ -152,7 +148,7 @@ module Projects
def process_prometheus_alerts
alerts.map do |alert|
AlertManagement::ProcessPrometheusAlertService
- .new(project, alert.to_h)
+ .new(project, alert)
.execute
end
end
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index dd1c2b94e18..bf90783fcbe 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -65,11 +65,20 @@ module Projects
def build_commit_status
GenericCommitStatus.new(
user: build.user,
- stage: 'deploy',
+ ci_stage: stage,
name: 'pages:deploy'
)
end
+ # rubocop: disable Performance/ActiveRecordSubtransactionMethods
+ def stage
+ build.pipeline.stages.safe_find_or_create_by(name: 'deploy', pipeline_id: build.pipeline.id) do |stage|
+ stage.position = GenericCommitStatus::EXTERNAL_STAGE_IDX
+ stage.project = build.project
+ end
+ end
+ # rubocop: enable Performance/ActiveRecordSubtransactionMethods
+
def create_pages_deployment(artifacts_path, build)
sha256 = build.job_artifacts_archive.file_sha256
File.open(artifacts_path) do |file|
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index 2588d2187a5..b7df201824a 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -4,6 +4,7 @@ module Releases
class CreateService < Releases::BaseService
def execute
return error('Access Denied', 403) unless allowed?
+ return error('You are not allowed to create this tag as it is protected.', 403) unless can_create_tag?
return error('Release already exists', 409) if release
return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
@@ -38,7 +39,7 @@ module Releases
end
def allowed?
- Ability.allowed?(current_user, :create_release, project) && can_create_tag?
+ Ability.allowed?(current_user, :create_release, project)
end
def can_create_tag?
diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb
index 04f917ec8ef..7e176f95db0 100644
--- a/app/services/resource_events/change_labels_service.rb
+++ b/app/services/resource_events/change_labels_service.rb
@@ -29,7 +29,10 @@ module ResourceEvents
resource.expire_note_etag_cache
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user) if resource.is_a?(Issue)
+ return unless resource.is_a?(Issue)
+
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user,
+ project: resource.project)
end
private
diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb
index 89cb14e6fff..7fd0fb10b4b 100644
--- a/app/services/service_ping/submit_service.rb
+++ b/app/services/service_ping/submit_service.rb
@@ -18,41 +18,20 @@ module ServicePing
def execute
return unless ServicePing::ServicePingSettings.product_intelligence_enabled?
- start = Time.current
- begin
- usage_data = payload || ServicePing::BuildPayload.new.execute
- response = submit_usage_data_payload(usage_data)
- rescue StandardError => e
- return unless Gitlab::CurrentSettings.usage_ping_enabled?
-
- error_payload = {
- time: Time.current,
- uuid: Gitlab::CurrentSettings.uuid,
- hostname: Gitlab.config.gitlab.host,
- version: Gitlab.version_info.to_s,
- message: "#{e.message.presence || e.class} at #{e.backtrace[0]}",
- elapsed: (Time.current - start).round(1)
- }
- submit_payload({ error: error_payload }, path: ERROR_PATH)
+ start_time = Time.current
- usage_data = payload || Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values)
- response = submit_usage_data_payload(usage_data)
- end
+ begin
+ response = submit_usage_data_payload
- version_usage_data_id =
- response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id')
+ raise SubmissionError, "Unsuccessful response code: #{response.code}" unless response.success?
- unless version_usage_data_id.is_a?(Integer) && version_usage_data_id > 0
- raise SubmissionError, "Invalid usage_data_id in response: #{version_usage_data_id}"
- end
+ handle_response(response)
+ submit_metadata_payload
+ rescue StandardError => e
+ submit_error_payload(e, start_time)
- unless skip_db_write
- raw_usage_data = save_raw_usage_data(usage_data)
- raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id)
- ServicePing::DevopsReport.new(response).execute
+ raise
end
-
- submit_payload(metadata(usage_data), path: METADATA_PATH)
end
private
@@ -90,14 +69,43 @@ module ServicePing
)
end
- def submit_usage_data_payload(usage_data)
- raise SubmissionError, 'Usage data is blank' if usage_data.blank?
+ def submit_usage_data_payload
+ raise SubmissionError, 'Usage data payload is blank' if payload.blank?
+
+ submit_payload(payload)
+ end
+
+ def handle_response(response)
+ version_usage_data_id =
+ response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id')
- response = submit_payload(usage_data)
+ unless version_usage_data_id.is_a?(Integer) && version_usage_data_id > 0
+ raise SubmissionError, "Invalid usage_data_id in response: #{version_usage_data_id}"
+ end
- raise SubmissionError, "Unsuccessful response code: #{response.code}" unless response.success?
+ return if skip_db_write
+
+ raw_usage_data = save_raw_usage_data(payload)
+ raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id)
+ ServicePing::DevopsReport.new(response).execute
+ end
+
+ def submit_error_payload(error, start_time)
+ current_time = Time.current
+ error_payload = {
+ time: current_time,
+ uuid: Gitlab::CurrentSettings.uuid,
+ hostname: Gitlab.config.gitlab.host,
+ version: Gitlab.version_info.to_s,
+ message: "#{error.message.presence || error.class} at #{error.backtrace[0]}",
+ elapsed: (current_time - start_time).round(1)
+ }
+
+ submit_payload({ error: error_payload }, path: ERROR_PATH)
+ end
- response
+ def submit_metadata_payload
+ submit_payload(metadata(payload), path: METADATA_PATH)
end
def save_raw_usage_data(usage_data)
diff --git a/app/services/service_response.rb b/app/services/service_response.rb
index c7ab75a4426..848f90e7f25 100644
--- a/app/services/service_response.rb
+++ b/app/services/service_response.rb
@@ -2,20 +2,28 @@
class ServiceResponse
def self.success(message: nil, payload: {}, http_status: :ok)
- new(status: :success, message: message, payload: payload, http_status: http_status)
+ new(status: :success,
+ message: message,
+ payload: payload,
+ http_status: http_status)
end
- def self.error(message:, payload: {}, http_status: nil)
- new(status: :error, message: message, payload: payload, http_status: http_status)
+ def self.error(message:, payload: {}, http_status: nil, reason: nil)
+ new(status: :error,
+ message: message,
+ payload: payload,
+ http_status: http_status,
+ reason: reason)
end
- attr_reader :status, :message, :http_status, :payload
+ attr_reader :status, :message, :http_status, :payload, :reason
- def initialize(status:, message: nil, payload: {}, http_status: nil)
+ def initialize(status:, message: nil, payload: {}, http_status: nil, reason: nil)
self.status = status
self.message = message
self.payload = payload
self.http_status = http_status
+ self.reason = reason
end
def track_exception(as: StandardError, **extra_data)
@@ -41,7 +49,11 @@ class ServiceResponse
end
def to_h
- (payload || {}).merge(status: status, message: message, http_status: http_status)
+ (payload || {}).merge(
+ status: status,
+ message: message,
+ http_status: http_status,
+ reason: reason)
end
def success?
@@ -60,5 +72,5 @@ class ServiceResponse
private
- attr_writer :status, :message, :http_status, :payload
+ attr_writer :status, :message, :http_status, :payload, :reason
end
diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb
index 1a04c4fcedd..42e62d65ee4 100644
--- a/app/services/snippets/base_service.rb
+++ b/app/services/snippets/base_service.rb
@@ -73,6 +73,15 @@ module Snippets
message
end
+ def file_paths_to_commit
+ paths = []
+ snippet_actions.to_commit_actions.each do |action|
+ paths << { path: action[:file_path] }
+ end
+
+ paths
+ end
+
def files_to_commit(snippet)
snippet_actions.to_commit_actions.presence || build_actions_from_params(snippet)
end
diff --git a/app/services/snippets/bulk_destroy_service.rb b/app/services/snippets/bulk_destroy_service.rb
index 6eab9fb320e..9c6e1c14051 100644
--- a/app/services/snippets/bulk_destroy_service.rb
+++ b/app/services/snippets/bulk_destroy_service.rb
@@ -14,10 +14,10 @@ module Snippets
@snippets = snippets
end
- def execute(options = {})
+ def execute(skip_authorization: false)
return ServiceResponse.success(message: 'No snippets found.') if snippets.empty?
- user_can_delete_snippets! unless options[:hard_delete]
+ user_can_delete_snippets! unless skip_authorization
attempt_delete_repositories!
snippets.destroy_all # rubocop: disable Cop/DestroyAll
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 6d3b63de9fd..e0bab4cd6ad 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -24,7 +24,8 @@ module Snippets
spammable: @snippet,
spam_params: spam_params,
user: current_user,
- action: :create
+ action: :create,
+ extra_features: { files: file_paths_to_commit }
).execute
if save_and_commit
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index 76d5063c337..067680f2abc 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -23,11 +23,14 @@ module Snippets
update_snippet_attributes(snippet)
+ files = snippet.all_files.map { |f| { path: f } } + file_paths_to_commit
+
Spam::SpamActionService.new(
spammable: snippet,
spam_params: spam_params,
user: current_user,
- action: :update
+ action: :update,
+ extra_features: { files: files }
).execute
if save_and_commit(snippet)
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index 4fa9c0e4993..9c52e9f0cd3 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -4,11 +4,12 @@ module Spam
class SpamActionService
include SpamConstants
- def initialize(spammable:, spam_params:, user:, action:)
+ def initialize(spammable:, spam_params:, user:, action:, extra_features: {})
@target = spammable
@spam_params = spam_params
@user = user
@action = action
+ @extra_features = extra_features
end
# rubocop:disable Metrics/AbcSize
@@ -40,7 +41,7 @@ module Spam
private
- attr_reader :user, :action, :target, :spam_params, :spam_log
+ attr_reader :user, :action, :target, :spam_params, :spam_log, :extra_features
##
# In order to be proceed to the spam check process, the target must be
@@ -124,7 +125,9 @@ module Spam
SpamVerdictService.new(target: target,
user: user,
options: options,
- context: context)
+ context: context,
+ extra_features: extra_features
+ )
end
def noteable_type
diff --git a/app/services/spam/spam_constants.rb b/app/services/spam/spam_constants.rb
index d300525710c..9ac3bcf8a1d 100644
--- a/app/services/spam/spam_constants.rb
+++ b/app/services/spam/spam_constants.rb
@@ -2,6 +2,7 @@
module Spam
module SpamConstants
+ ERROR_TYPE = 'spamcheck'
BLOCK_USER = 'block'
DISALLOW = 'disallow'
CONDITIONAL_ALLOW = 'conditional_allow'
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index e73b2666c02..08634ec840c 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -5,11 +5,12 @@ module Spam
include AkismetMethods
include SpamConstants
- def initialize(user:, target:, options:, context: {})
+ def initialize(user:, target:, options:, context: {}, extra_features: {})
@target = target
@user = user
@options = options
@context = context
+ @extra_features = extra_features
end
def execute
@@ -61,7 +62,7 @@ module Spam
private
- attr_reader :user, :target, :options, :context
+ attr_reader :user, :target, :options, :context, :extra_features
def akismet_verdict
if akismet.spam?
@@ -75,7 +76,8 @@ module Spam
return unless Gitlab::CurrentSettings.spam_check_endpoint_enabled
begin
- result, attribs, _error = spamcheck_client.issue_spam?(spam_issue: target, user: user, context: context)
+ result, attribs, _error = spamcheck_client.spam?(spammable: target, user: user, context: context,
+ extra_features: extra_features)
# @TODO log if error is not nil https://gitlab.com/gitlab-org/gitlab/-/issues/329545
return [nil, attribs] unless result
@@ -83,7 +85,7 @@ module Spam
[result, attribs]
rescue StandardError => e
- Gitlab::ErrorTracking.log_exception(e)
+ Gitlab::ErrorTracking.log_exception(e, error: ERROR_TYPE)
# Default to ALLOW if any errors occur
[ALLOW, attribs, true]
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 75903fde39e..7275a05d2ce 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -14,6 +14,13 @@ module SystemNotes
# See also the discussion in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60700#note_612724683
USE_COMMIT_DATE_FOR_CROSS_REFERENCE_NOTE = false
+ def self.issuable_events
+ {
+ review_requested: s_('IssuableEvents|requested review from'),
+ review_request_removed: s_('IssuableEvents|removed review request for')
+ }.freeze
+ end
+
#
# noteable_ref - Referenced noteable object
#
@@ -26,7 +33,7 @@ module SystemNotes
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)
+ track_issue_event(:track_issue_related_action)
create_note(NoteSummary.new(noteable, project, author, body, action: 'relate'))
end
@@ -42,7 +49,7 @@ module SystemNotes
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)
+ track_issue_event(:track_issue_unrelated_action)
create_note(NoteSummary.new(noteable, project, author, body, action: 'unrelate'))
end
@@ -61,7 +68,7 @@ module SystemNotes
def change_assignee(assignee)
body = assignee.nil? ? 'removed assignee' : "assigned to #{assignee.to_reference}"
- issue_activity_counter.track_issue_assignee_changed_action(author: author) if noteable.is_a?(Issue)
+ track_issue_event(:track_issue_assignee_changed_action)
create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
end
@@ -93,7 +100,7 @@ module SystemNotes
body = text_parts.join(' and ')
- issue_activity_counter.track_issue_assignee_changed_action(author: author) if noteable.is_a?(Issue)
+ track_issue_event(:track_issue_assignee_changed_action)
create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
end
@@ -115,8 +122,8 @@ module SystemNotes
text_parts = []
Gitlab::I18n.with_default_locale do
- text_parts << "requested review from #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
- text_parts << "removed review request for #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
+ text_parts << "#{self.class.issuable_events[:review_requested]} #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
+ text_parts << "#{self.class.issuable_events[:review_request_removed]} #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
end
body = text_parts.join(' and ')
@@ -172,7 +179,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)
+ track_issue_event(:track_issue_title_changed_action)
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'))
@@ -210,7 +217,7 @@ module SystemNotes
def change_description
body = 'changed the description'
- issue_activity_counter.track_issue_description_changed_action(author: author) if noteable.is_a?(Issue)
+ track_issue_event(:track_issue_description_changed_action)
create_note(NoteSummary.new(noteable, project, author, body, action: 'description'))
end
@@ -246,6 +253,7 @@ module SystemNotes
)
else
track_cross_reference_action
+
created_at = mentioner.created_at if USE_COMMIT_DATE_FOR_CROSS_REFERENCE_NOTE && mentioner.is_a?(Commit)
create_note(NoteSummary.new(noteable, noteable.project, author, body, action: 'cross_reference', created_at: created_at))
end
@@ -280,7 +288,7 @@ module SystemNotes
status_label = new_task.complete? ? Taskable::COMPLETED : Taskable::INCOMPLETE
body = "marked the checklist item **#{new_task.source}** as #{status_label}"
- issue_activity_counter.track_issue_description_changed_action(author: author) if noteable.is_a?(Issue)
+ track_issue_event(:track_issue_description_changed_action)
create_note(NoteSummary.new(noteable, project, author, body, action: 'task'))
end
@@ -303,7 +311,7 @@ module SystemNotes
cross_reference = noteable_ref.to_reference(project)
body = "moved #{direction} #{cross_reference}"
- issue_activity_counter.track_issue_moved_action(author: author) if noteable.is_a?(Issue)
+ track_issue_event(:track_issue_moved_action)
create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
end
@@ -327,9 +335,7 @@ module SystemNotes
cross_reference = noteable_ref.to_reference(project)
body = "cloned #{direction} #{cross_reference}"
- if noteable.is_a?(Issue) && direction == :to
- issue_activity_counter.track_issue_cloned_action(author: author, project: project)
- end
+ track_issue_event(:track_issue_cloned_action) if direction == :to
create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned', created_at: created_at))
end
@@ -346,12 +352,12 @@ module SystemNotes
body = 'made the issue confidential'
action = 'confidential'
- issue_activity_counter.track_issue_made_confidential_action(author: author) if noteable.is_a?(Issue)
+ track_issue_event(:track_issue_made_confidential_action)
else
body = 'made the issue visible to everyone'
action = 'visible'
- issue_activity_counter.track_issue_made_visible_action(author: author) if noteable.is_a?(Issue)
+ track_issue_event(:track_issue_made_visible_action)
end
create_note(NoteSummary.new(noteable, project, author, body, action: action))
@@ -418,7 +424,7 @@ module SystemNotes
def mark_duplicate_issue(canonical_issue)
body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}"
- issue_activity_counter.track_issue_marked_as_duplicate_action(author: author) if noteable.is_a?(Issue)
+ track_issue_event(:track_issue_marked_as_duplicate_action)
create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
end
@@ -431,12 +437,10 @@ module SystemNotes
action = noteable.discussion_locked? ? 'locked' : 'unlocked'
body = "#{action} this #{noteable.class.to_s.titleize.downcase}"
- if noteable.is_a?(Issue)
- if action == 'locked'
- issue_activity_counter.track_issue_locked_action(author: author)
- else
- issue_activity_counter.track_issue_unlocked_action(author: author)
- end
+ if action == 'locked'
+ track_issue_event(:track_issue_locked_action)
+ else
+ track_issue_event(:track_issue_unlocked_action)
end
create_note(NoteSummary.new(noteable, project, author, body, action: action))
@@ -495,7 +499,7 @@ module SystemNotes
end
def track_cross_reference_action
- issue_activity_counter.track_issue_cross_referenced_action(author: author) if noteable.is_a?(Issue)
+ track_issue_event(:track_issue_cross_referenced_action)
end
def hierarchy_note_params(action, parent, child)
@@ -520,6 +524,12 @@ module SystemNotes
}
end
end
+
+ def track_issue_event(event_name)
+ return unless noteable.is_a?(Issue)
+
+ issue_activity_counter.public_send(event_name, author: author, project: project || noteable.project) # rubocop: disable GitlabSecurity/PublicSend
+ end
end
end
diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb
index 68df52a03c7..c5bdbc6799e 100644
--- a/app/services/system_notes/time_tracking_service.rb
+++ b/app/services/system_notes/time_tracking_service.rb
@@ -21,7 +21,7 @@ module SystemNotes
# Using instance_of because WorkItem < Issue. We don't want to track work item updates as issue updates
if noteable.instance_of?(Issue) && changed_dates.key?('due_date')
- issue_activity_counter.track_issue_due_date_changed_action(author: author)
+ issue_activity_counter.track_issue_due_date_changed_action(author: author, project: project)
end
work_item_activity_counter.track_work_item_date_changed_action(author: author) if noteable.is_a?(WorkItem)
@@ -50,7 +50,9 @@ module SystemNotes
"changed time estimate to #{parsed_time}"
end
- issue_activity_counter.track_issue_time_estimate_changed_action(author: author) if noteable.is_a?(Issue)
+ if noteable.is_a?(Issue)
+ issue_activity_counter.track_issue_time_estimate_changed_action(author: author, project: project)
+ end
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
end
@@ -81,7 +83,9 @@ module SystemNotes
body = text_parts.join(' ')
end
- issue_activity_counter.track_issue_time_spent_changed_action(author: author) if noteable.is_a?(Issue)
+ if noteable.is_a?(Issue)
+ issue_activity_counter.track_issue_time_spent_changed_action(author: author, project: project)
+ end
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
end
@@ -107,7 +111,9 @@ module SystemNotes
text_parts << "at #{spent_at}" if spent_at && spent_at != DateTime.current.to_date
body = text_parts.join(' ')
- issue_activity_counter.track_issue_time_spent_changed_action(author: author) if noteable.is_a?(Issue)
+ if noteable.is_a?(Issue)
+ issue_activity_counter.track_issue_time_spent_changed_action(author: author, project: project)
+ end
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
end
diff --git a/app/services/topics/merge_service.rb b/app/services/topics/merge_service.rb
index 0d256579fe0..58f3d5305b4 100644
--- a/app/services/topics/merge_service.rb
+++ b/app/services/topics/merge_service.rb
@@ -17,14 +17,21 @@ module Topics
refresh_target_topic_counters
delete_source_topic
end
+
+ ServiceResponse.success
+ rescue ArgumentError => e
+ ServiceResponse.error(message: e.message)
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, source_topic_id: source_topic.id, target_topic_id: target_topic.id)
+ ServiceResponse.error(message: _('Topics could not be merged!'))
end
private
def validate_parameters!
- raise ArgumentError, 'The source topic is not a topic.' unless source_topic.is_a?(Projects::Topic)
- raise ArgumentError, 'The target topic is not a topic.' unless target_topic.is_a?(Projects::Topic)
- raise ArgumentError, 'The source topic and the target topic are identical.' if source_topic == target_topic
+ raise ArgumentError, _('The source topic is not a topic.') unless source_topic.is_a?(Projects::Topic)
+ raise ArgumentError, _('The target topic is not a topic.') unless target_topic.is_a?(Projects::Topic)
+ raise ArgumentError, _('The source topic and the target topic are identical.') if source_topic == target_topic
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/services/users/authorized_build_service.rb b/app/services/users/authorized_build_service.rb
index eb2386198d3..5029105b087 100644
--- a/app/services/users/authorized_build_service.rb
+++ b/app/services/users/authorized_build_service.rb
@@ -16,3 +16,5 @@ module Users
end
end
end
+
+Users::AuthorizedBuildService.prepend_mod_with('Users::AuthorizedBuildService')
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index dfa9316889e..a378cb09854 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -23,6 +23,11 @@ module Users
# `hard_delete: true` implies `delete_solo_owned_groups: true`. To perform
# a hard deletion without destroying solo-owned groups, pass
# `delete_solo_owned_groups: false, hard_delete: true` in +options+.
+ #
+ # To make the service asynchronous, a new behaviour is being introduced
+ # behind the user_destroy_with_limited_execution_time_worker feature flag.
+ # Migrating the associated user records, and post-migration cleanup is
+ # handled by the Users::MigrateRecordsToGhostUserWorker cron worker.
def execute(user, options = {})
delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete])
@@ -35,12 +40,14 @@ module Users
return user
end
- # Calling all before/after_destroy hooks for the user because
- # there is no dependent: destroy in the relationship. And the removal
- # is done by a foreign_key. Otherwise they won't be called
- user.members.find_each { |member| member.run_callbacks(:destroy) }
+ user.block
+
+ # Load the records. Groups are unavailable after membership is destroyed.
+ solo_owned_groups = user.solo_owned_groups.load
+
+ user.members.each_batch { |batch| batch.destroy_all } # rubocop:disable Style/SymbolProc, Cop/DestroyAll
- user.solo_owned_groups.each do |group|
+ solo_owned_groups.each do |group|
Groups::DestroyService.new(group, current_user).execute
end
@@ -54,22 +61,32 @@ module Users
yield(user) if block_given?
- MigrateToGhostUserService.new(user).execute(hard_delete: options[:hard_delete])
+ hard_delete = options.fetch(:hard_delete, false)
- response = Snippets::BulkDestroyService.new(current_user, user.snippets).execute(options)
- raise DestroyError, response.message if response.error?
+ if Feature.enabled?(:user_destroy_with_limited_execution_time_worker)
+ Users::GhostUserMigration.create!(user: user,
+ initiator_user: current_user,
+ hard_delete: hard_delete)
- # Rails attempts to load all related records into memory before
- # destroying: https://github.com/rails/rails/issues/22510
- # This ensures we delete records in batches.
- user.destroy_dependent_associations_in_batches(exclude: [:snippets])
- user.nullify_dependent_associations_in_batches
+ else
+ MigrateToGhostUserService.new(user).execute(hard_delete: options[:hard_delete])
- # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
- user_data = user.destroy
- namespace.destroy
+ response = Snippets::BulkDestroyService.new(current_user, user.snippets)
+ .execute(skip_authorization: hard_delete)
+ raise DestroyError, response.message if response.error?
- user_data
+ # Rails attempts to load all related records into memory before
+ # destroying: https://github.com/rails/rails/issues/22510
+ # This ensures we delete records in batches.
+ user.destroy_dependent_associations_in_batches(exclude: [:snippets])
+ user.nullify_dependent_associations_in_batches
+
+ # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
+ user_data = user.destroy
+ namespace.destroy
+
+ user_data
+ end
end
end
end
diff --git a/app/services/users/email_verification/base_service.rb b/app/services/users/email_verification/base_service.rb
new file mode 100644
index 00000000000..3337beec195
--- /dev/null
+++ b/app/services/users/email_verification/base_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Users
+ module EmailVerification
+ class BaseService
+ VALID_ATTRS = %i[unlock_token confirmation_token].freeze
+
+ def initialize(attr:)
+ @attr = attr
+
+ validate_attr!
+ end
+
+ protected
+
+ attr_reader :attr, :token
+
+ def validate_attr!
+ raise ArgumentError, 'Invalid attribute' unless attr.in?(VALID_ATTRS)
+ end
+
+ def digest
+ Devise.token_generator.digest(User, attr, token)
+ end
+ end
+ end
+end
diff --git a/app/services/users/email_verification/generate_token_service.rb b/app/services/users/email_verification/generate_token_service.rb
new file mode 100644
index 00000000000..6f0237ce244
--- /dev/null
+++ b/app/services/users/email_verification/generate_token_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Users
+ module EmailVerification
+ class GenerateTokenService < EmailVerification::BaseService
+ TOKEN_LENGTH = 6
+
+ def execute
+ @token = generate_token
+
+ [token, digest]
+ end
+
+ private
+
+ def generate_token
+ SecureRandom.random_number(10**TOKEN_LENGTH).to_s.rjust(TOKEN_LENGTH, '0')
+ end
+ end
+ end
+end
diff --git a/app/services/users/email_verification/validate_token_service.rb b/app/services/users/email_verification/validate_token_service.rb
new file mode 100644
index 00000000000..b1b34e94f49
--- /dev/null
+++ b/app/services/users/email_verification/validate_token_service.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Users
+ module EmailVerification
+ class ValidateTokenService < EmailVerification::BaseService
+ include ActionView::Helpers::DateHelper
+
+ TOKEN_VALID_FOR_MINUTES = 60
+
+ def initialize(attr:, user:, token:)
+ super(attr: attr)
+
+ @user = user
+ @token = token
+ end
+
+ def execute
+ return failure(:rate_limited) if verification_rate_limited?
+ return failure(:invalid) unless valid?
+ return failure(:expired) if expired_token?
+
+ success
+ end
+
+ private
+
+ attr_reader :user
+
+ def verification_rate_limited?
+ Gitlab::ApplicationRateLimiter.throttled?(:email_verification, scope: user[attr])
+ end
+
+ def valid?
+ return false unless token.present?
+
+ Devise.secure_compare(user[attr], digest)
+ end
+
+ def expired_token?
+ generated_at = case attr
+ when :unlock_token then user.locked_at
+ when :confirmation_token then user.confirmation_sent_at
+ end
+
+ generated_at < TOKEN_VALID_FOR_MINUTES.minutes.ago
+ end
+
+ def success
+ { status: :success }
+ end
+
+ def failure(reason)
+ {
+ status: :failure,
+ reason: reason,
+ message: failure_message(reason)
+ }
+ end
+
+ def failure_message(reason)
+ case reason
+ when :rate_limited
+ format(s_("IdentityVerification|You've reached the maximum amount of tries. "\
+ 'Wait %{interval} or send a new code and try again.'), interval: email_verification_interval)
+ when :expired
+ s_('IdentityVerification|The code has expired. Send a new code and try again.')
+ when :invalid
+ s_('IdentityVerification|The code is incorrect. Enter it again, or send a new code.')
+ end
+ end
+
+ def email_verification_interval
+ interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:email_verification][:interval]
+ distance_of_time_in_words(interval_in_seconds)
+ end
+ end
+ end
+end
diff --git a/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb
new file mode 100644
index 00000000000..7c4a5698ea9
--- /dev/null
+++ b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Users
+ class MigrateRecordsToGhostUserInBatchesService
+ def initialize
+ @execution_tracker = Gitlab::Utils::ExecutionTracker.new
+ end
+
+ def execute
+ Users::GhostUserMigration.find_each do |user_to_migrate|
+ break if execution_tracker.over_limit?
+
+ service = Users::MigrateRecordsToGhostUserService.new(user_to_migrate.user,
+ user_to_migrate.initiator_user,
+ execution_tracker)
+ service.execute(hard_delete: user_to_migrate.hard_delete)
+ end
+ rescue Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError
+ # no-op
+ end
+
+ private
+
+ attr_reader :execution_tracker
+ end
+end
diff --git a/app/services/users/migrate_records_to_ghost_user_service.rb b/app/services/users/migrate_records_to_ghost_user_service.rb
new file mode 100644
index 00000000000..2d92aaed7da
--- /dev/null
+++ b/app/services/users/migrate_records_to_ghost_user_service.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+# When a user is destroyed, some of their associated records are
+# moved to a "Ghost User", to prevent these associated records from
+# being destroyed.
+#
+# For example, all the issues/MRs a user has created are _not_ destroyed
+# when the user is destroyed.
+module Users
+ class MigrateRecordsToGhostUserService
+ extend ActiveSupport::Concern
+
+ DestroyError = Class.new(StandardError)
+
+ attr_reader :ghost_user, :user, :initiator_user, :hard_delete
+
+ def initialize(user, initiator_user, execution_tracker)
+ @user = user
+ @initiator_user = initiator_user
+ @execution_tracker = execution_tracker
+ @ghost_user = User.ghost
+ end
+
+ def execute(hard_delete: false)
+ @hard_delete = hard_delete
+
+ migrate_records
+ post_migrate_records
+ end
+
+ private
+
+ attr_reader :execution_tracker
+
+ def migrate_records
+ return if hard_delete
+
+ migrate_issues
+ migrate_merge_requests
+ migrate_notes
+ migrate_abuse_reports
+ migrate_award_emoji
+ migrate_snippets
+ migrate_reviews
+ end
+
+ def post_migrate_records
+ delete_snippets
+
+ # Rails attempts to load all related records into memory before
+ # destroying: https://github.com/rails/rails/issues/22510
+ # This ensures we delete records in batches.
+ user.destroy_dependent_associations_in_batches(exclude: [:snippets])
+ user.nullify_dependent_associations_in_batches
+
+ # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
+ user_data = user.destroy
+ user.namespace.destroy
+
+ user_data
+ end
+
+ def delete_snippets
+ response = Snippets::BulkDestroyService.new(initiator_user, user.snippets).execute(skip_authorization: true)
+ raise DestroyError, response.message if response.error?
+ end
+
+ def migrate_issues
+ batched_migrate(Issue, :author_id)
+ batched_migrate(Issue, :last_edited_by_id)
+ end
+
+ def migrate_merge_requests
+ batched_migrate(MergeRequest, :author_id)
+ batched_migrate(MergeRequest, :merge_user_id)
+ end
+
+ def migrate_notes
+ batched_migrate(Note, :author_id)
+ end
+
+ def migrate_abuse_reports
+ user.reported_abuse_reports.update_all(reporter_id: ghost_user.id)
+ end
+
+ def migrate_award_emoji
+ user.award_emoji.update_all(user_id: ghost_user.id)
+ end
+
+ def migrate_snippets
+ snippets = user.snippets.only_project_snippets
+ snippets.update_all(author_id: ghost_user.id)
+ end
+
+ def migrate_reviews
+ batched_migrate(Review, :author_id)
+ end
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ def batched_migrate(base_scope, column, batch_size: 50)
+ loop do
+ update_count = base_scope.where(column => user.id).limit(batch_size).update_all(column => ghost_user.id)
+ break if update_count == 0
+ raise Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError if execution_tracker.over_limit?
+ end
+ end
+ # rubocop:enable CodeReuse/ActiveRecord
+ end
+end
+
+Users::MigrateRecordsToGhostUserService.prepend_mod_with('Users::MigrateRecordsToGhostUserService')
diff --git a/app/uploaders/object_storage/cdn.rb b/app/uploaders/object_storage/cdn.rb
new file mode 100644
index 00000000000..0711ab0bd28
--- /dev/null
+++ b/app/uploaders/object_storage/cdn.rb
@@ -0,0 +1,46 @@
+# rubocop:disable Naming/FileName
+# frozen_string_literal: true
+
+require_relative 'cdn/google_cdn'
+
+module ObjectStorage
+ module CDN
+ module Concern
+ extend ActiveSupport::Concern
+
+ include Gitlab::Utils::StrongMemoize
+
+ def use_cdn?(request_ip)
+ return false unless cdn_options.is_a?(Hash) && cdn_options['provider']
+ return false unless cdn_provider
+
+ cdn_provider.use_cdn?(request_ip)
+ end
+
+ def cdn_signed_url
+ cdn_provider&.signed_url(path)
+ end
+
+ private
+
+ def cdn_provider
+ strong_memoize(:cdn_provider) do
+ provider = cdn_options['provider']&.downcase
+
+ next unless provider
+ next GoogleCDN.new(cdn_options) if provider == 'google'
+
+ raise "Unknown CDN provider: #{provider}"
+ end
+ end
+
+ def cdn_options
+ return {} unless options.object_store.key?('cdn')
+
+ options.object_store.cdn
+ end
+ end
+ end
+end
+
+# rubocop:enable Naming/FileName
diff --git a/app/uploaders/object_storage/cdn/google_cdn.rb b/app/uploaders/object_storage/cdn/google_cdn.rb
new file mode 100644
index 00000000000..ea7683f131c
--- /dev/null
+++ b/app/uploaders/object_storage/cdn/google_cdn.rb
@@ -0,0 +1,71 @@
+# rubocop:disable Naming/FileName
+# frozen_string_literal: true
+
+module ObjectStorage
+ module CDN
+ class GoogleCDN
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :options
+
+ def initialize(options)
+ @options = HashWithIndifferentAccess.new(options.to_h)
+
+ GoogleIpCache.async_refresh unless GoogleIpCache.ready?
+ end
+
+ def use_cdn?(request_ip)
+ return false unless config_valid?
+
+ ip = IPAddr.new(request_ip)
+
+ return false if ip.private?
+
+ !GoogleIpCache.google_ip?(request_ip)
+ end
+
+ def signed_url(path, expiry: 10.minutes)
+ expiration = (Time.current + expiry).utc.to_i
+
+ uri = Addressable::URI.parse(cdn_url)
+ uri.path = path
+ uri.query = "Expires=#{expiration}&KeyName=#{key_name}"
+
+ signature = OpenSSL::HMAC.digest('SHA1', decoded_key, uri.to_s)
+ encoded_signature = Base64.urlsafe_encode64(signature)
+
+ uri.query += "&Signature=#{encoded_signature}"
+ uri.to_s
+ end
+
+ private
+
+ def config_valid?
+ [key_name, decoded_key, cdn_url].all?(&:present?)
+ end
+
+ def key_name
+ strong_memoize(:key_name) do
+ options['key_name']
+ end
+ end
+
+ def decoded_key
+ strong_memoize(:decoded_key) do
+ Base64.urlsafe_decode64(options['key']) if options['key']
+ rescue ArgumentError
+ Gitlab::ErrorTracking.log_exception(ArgumentError.new("Google CDN key is not base64-encoded"))
+ nil
+ end
+ end
+
+ def cdn_url
+ strong_memoize(:cdn_url) do
+ options['url']
+ end
+ end
+ end
+ end
+end
+
+# rubocop:enable Naming/FileName
diff --git a/app/uploaders/object_storage/cdn/google_ip_cache.rb b/app/uploaders/object_storage/cdn/google_ip_cache.rb
new file mode 100644
index 00000000000..35ec7ce0c6e
--- /dev/null
+++ b/app/uploaders/object_storage/cdn/google_ip_cache.rb
@@ -0,0 +1,60 @@
+# rubocop:disable Naming/FileName
+# frozen_string_literal: true
+
+module ObjectStorage
+ module CDN
+ class GoogleIpCache
+ GOOGLE_CDN_LIST_KEY = 'google_cdn_ip_list'
+ CACHE_EXPIRATION_TIME = 1.day
+
+ class << self
+ def update!(subnets)
+ caches.each { |cache| cache.write(GOOGLE_CDN_LIST_KEY, subnets) }
+ end
+
+ def ready?
+ caches.any? { |cache| cache.exist?(GOOGLE_CDN_LIST_KEY) }
+ end
+
+ def google_ip?(request_ip)
+ google_ip_ranges = cached_value(GOOGLE_CDN_LIST_KEY)
+
+ return false unless google_ip_ranges
+
+ google_ip_ranges.any? { |range| range.include?(request_ip) }
+ end
+
+ def async_refresh
+ ::GoogleCloud::FetchGoogleIpListWorker.perform_async
+ end
+
+ private
+
+ def caches
+ [l1_cache, l2_cache]
+ end
+
+ def l1_cache
+ Gitlab::ProcessMemoryCache.cache_backend
+ end
+
+ def l2_cache
+ Rails.cache
+ end
+
+ def cached_value(key)
+ l1_cache.fetch(key) do
+ result = l2_cache.fetch(key)
+
+ # Don't populate the L1 cache if we can't find the entry
+ break unless result
+
+ result
+ end
+ end
+ end
+ end
+ end
+end
+
+# rubocop:enable Naming/FileName
diff --git a/app/uploaders/packages/package_file_uploader.rb b/app/uploaders/packages/package_file_uploader.rb
index 4b6dbe5b358..9c0a88c9bf8 100644
--- a/app/uploaders/packages/package_file_uploader.rb
+++ b/app/uploaders/packages/package_file_uploader.rb
@@ -22,8 +22,6 @@ class Packages::PackageFileUploader < GitlabUploader
def dynamic_segment
raise ObjectNotReadyError, "Package model not ready" unless model.id
- package_segment = model.package.debian? ? 'debian' : model.package.id
-
- Gitlab::HashedPath.new('packages', package_segment, 'files', model.id, root_hash: model.package.project_id)
+ Gitlab::HashedPath.new('packages', model.package_id, 'files', model.id, root_hash: model.package.project_id)
end
end
diff --git a/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json b/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json
new file mode 100644
index 00000000000..70112d7e414
--- /dev/null
+++ b/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json
@@ -0,0 +1,10 @@
+{
+ "description": "Merge request predictions suggested reviewers",
+ "type": "object",
+ "properties": {
+ "top_n": { "type": "number" },
+ "version": { "type": "string" },
+ "changes": { "type": "array" }
+ },
+ "additionalProperties": true
+}
diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml
index 258fdb4ad9a..aaa85e81bd4 100644
--- a/app/views/abuse_reports/new.html.haml
+++ b/app/views/abuse_reports/new.html.haml
@@ -6,8 +6,8 @@
%p
= _("A member of the abuse team will review your report as soon as possible.")
%hr
-= form_for @abuse_report, html: { class: 'js-quick-submit js-requires-input'} do |f|
- = form_errors(@abuse_report, pajamas_alert: true)
+= gitlab_ui_form_for @abuse_report, html: { class: 'js-quick-submit js-requires-input'} do |f|
+ = form_errors(@abuse_report)
= f.hidden_field :user_id
.form-group.row
@@ -25,4 +25,4 @@
= _("Explain the problem. If appropriate, provide a link to the relevant issue or comment.")
.form-actions
- = f.submit _("Send report"), class: "gl-button btn btn-confirm"
+ = f.submit _("Send report"), pajamas_button: true
diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml
index fbadd26d0c0..1878db419f7 100644
--- a/app/views/admin/application_settings/_abuse.html.haml
+++ b/app/views/admin/application_settings/_abuse.html.haml
@@ -1,9 +1,9 @@
-= form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-abuse-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+= gitlab_ui_form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-abuse-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
%fieldset
.form-group
= f.label :abuse_notification_email, _('Abuse reports notification email'), class: 'label-bold'
= f.text_field :abuse_notification_email, class: 'form-control gl-form-input'
- = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
+ = f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index e7204f635e6..c0e42f22119 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-account-settings'), html: { class: 'fieldset-form', id: 'account-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
@@ -46,13 +46,19 @@
= f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-form-input gl-mt-2'
.help-block
= _('Specify an email address regex pattern to identify default internal users.')
- = link_to _('Learn more'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), target: '_blank', rel: 'noopener noreferrer'
- unless Gitlab.com?
.form-group
= f.label :deactivate_dormant_users, _('Dormant users'), class: 'label-bold'
- dormant_users_help_link = help_page_path('user/admin_area/moderate_users', anchor: 'automatically-deactivate-dormant-users')
- dormant_users_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: dormant_users_help_link }
- = f.gitlab_ui_checkbox_component :deactivate_dormant_users, _('Deactivate dormant users after 90 days of inactivity'), help_text: _('Users can reactivate their account by signing in. %{link_start}Learn more%{link_end}').html_safe % { link_start: dormant_users_help_link_start, link_end: '</a>'.html_safe }
+ = f.gitlab_ui_checkbox_component :deactivate_dormant_users, _('Deactivate dormant users after a period of inactivity'), help_text: _('Users can reactivate their account by signing in. %{link_start}Learn more.%{link_end}').html_safe % { link_start: dormant_users_help_link_start, link_end: '</a>'.html_safe }
+ .form-group
+ = f.label :deactivate_dormant_users_period, _('Period of inactivity (days)'), class: 'label-light'
+ = f.number_field :deactivate_dormant_users_period, class: 'form-control gl-form-input', min: '1'
+ .form-text.text-muted
+ = _('Period of inactivity before deactivation.')
+
.form-group
= f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light'
= f.text_field :personal_access_token_prefix, placeholder: _('Maximum 20 characters'), class: 'form-control gl-form-input'
@@ -60,6 +66,7 @@
= f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold'
= f.gitlab_ui_checkbox_component :user_show_add_ssh_key_message, _("Inform users without uploaded SSH keys that they can't push over SSH until one is added")
+ = render 'admin/application_settings/invitation_flow_enforcement', form: f
= render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f
= render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f
- = f.submit _('Save changes'), class: 'gl-button btn btn-confirm qa-save-changes-button'
+ = f.submit _('Save changes'), class: 'qa-save-changes-button', pajamas_button: true
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index 77170761448..05aea2b343d 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -1,6 +1,6 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true )
+ = form_errors(@application_setting )
%fieldset
.form-group
@@ -53,8 +53,10 @@
= link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'specify-a-custom-cicd-configuration-file'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= f.gitlab_ui_checkbox_component :suggest_pipeline_enabled, s_('AdminSettings|Enable pipeline suggestion banner'), help_text: s_('AdminSettings|Display a banner on merge requests in projects with no pipelines to initiate steps to add a .gitlab-ci.yml file.')
+ - if Feature.enabled?(:enforce_runner_token_expires_at)
+ #js-runner-token-expiration-intervals{ data: runner_token_expiration_interval_attributes }
- = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
+ = f.submit _('Save changes'), pajamas_button: true
.settings-content
%h4
@@ -71,8 +73,8 @@
.tab-content.gl-tab-content
- @plans.each_with_index do |plan, index|
.tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' }
- = form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' }, method: :post do |f|
- = form_errors(plan, pajamas_alert: true)
+ = gitlab_ui_form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' }, method: :post do |f|
+ = form_errors(plan)
%fieldset
= f.hidden_field(:plan_id, value: plan.id)
.form-group
@@ -99,4 +101,4 @@
.form-group
= f.label :ci_registered_project_runners, s_('AdminSettings|Maximum number of runners registered per project')
= f.number_field :ci_registered_project_runners, class: 'form-control gl-form-input'
- = f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, class: 'btn gl-button btn-confirm'
+ = f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true
diff --git a/app/views/admin/application_settings/_default_branch.html.haml b/app/views/admin/application_settings/_default_branch.html.haml
index f9b1aa22b7a..7be4bac02fd 100644
--- a/app/views/admin/application_settings/_default_branch.html.haml
+++ b/app/views/admin/application_settings/_default_branch.html.haml
@@ -1,5 +1,5 @@
= 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, pajamas_alert: true)
+ = form_errors(@application_setting)
- fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value}</code>"
diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml
index 30165139711..2e8eb25b1d5 100644
--- a/app/views/admin/application_settings/_diff_limits.html.haml
+++ b/app/views/admin/application_settings/_diff_limits.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-merge-request-settings'), html: { class: 'fieldset-form', id: 'merge-request-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index 68eb33d6552..0bb9be497d9 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -10,7 +10,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form', id: 'eks-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml
index 774c5665edd..fd65d4029f5 100644
--- a/app/views/admin/application_settings/_email.html.haml
+++ b/app/views/admin/application_settings/_email.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-email-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_error_tracking.html.haml b/app/views/admin/application_settings/_error_tracking.html.haml
index 2dcd9d0d2c0..5a8aba5784e 100644
--- a/app/views/admin/application_settings/_error_tracking.html.haml
+++ b/app/views/admin/application_settings/_error_tracking.html.haml
@@ -25,7 +25,7 @@
data: { confirm: _('Are you sure you want to reset the error tracking access token?') }
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-error-tracking-settings'), html: { class: 'fieldset-form', id: 'error-tracking-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true) if expanded
+ = form_errors(@application_setting) if expanded
%fieldset
.sub-section
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
index f287dba9866..7919fde631f 100644
--- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -10,7 +10,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form', id: 'external-auth-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml
index d63eb2bd09d..e56ba635890 100644
--- a/app/views/admin/application_settings/_floc.html.haml
+++ b/app/views/admin/application_settings/_floc.html.haml
@@ -3,19 +3,20 @@
%section.settings.no-animate#js-floc-settings{ class: ('expanded' if expanded) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- = s_('FloC|Federated Learning of Cohorts')
+ = s_('FloC|Federated Learning of Cohorts (FLoC)')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
%p
- = s_('FloC|Configure whether you want to participate in FloC.').html_safe
- = link_to sprite_icon('question-o'), 'https://github.com/WICG/floc', target: '_blank', rel: 'noopener noreferrer', class: 'has-tooltip', title: _('More information')
+ - floc_link_url = help_page_path('user/admin_area/settings/floc.md')
+ - floc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: floc_link_url }
+ = html_escape(s_('FloC|Configure whether you want to participate in FLoC. %{floc_link_start}What is FLoC?%{floc_link_end}')) % { floc_link_start: floc_link_start, floc_link_end: '</a>'.html_safe }
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-floc-settings'), html: { class: 'fieldset-form', id: 'floc-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
= f.gitlab_ui_checkbox_component :floc_enabled,
- s_('FloC|Enable FloC (Federated Learning of Cohorts)')
+ s_('FloC|Participate in FLoC')
= f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_git_lfs_limits.html.haml b/app/views/admin/application_settings/_git_lfs_limits.html.haml
index 7d47ca9a139..b8970a5bcf1 100644
--- a/app/views/admin/application_settings/_git_lfs_limits.html.haml
+++ b/app/views/admin/application_settings/_git_lfs_limits.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-git-lfs-limits-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
%h5
diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml
index cc2c6dbcb03..ade6dac606a 100644
--- a/app/views/admin/application_settings/_gitaly.html.haml
+++ b/app/views/admin/application_settings/_gitaly.html.haml
@@ -1,5 +1,5 @@
= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-gitaly-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml
index cc1e3f968cb..df534f18bde 100644
--- a/app/views/admin/application_settings/_gitpod.html.haml
+++ b/app/views/admin/application_settings/_gitpod.html.haml
@@ -13,7 +13,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-gitpod-settings'), html: { class: 'fieldset-form', id: 'gitpod-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml
index f17f63c7df7..7f305b9ad9c 100644
--- a/app/views/admin/application_settings/_grafana.html.haml
+++ b/app/views/admin/application_settings/_grafana.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-grafana-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml
index 08a4ebe5c71..21eb4caf579 100644
--- a/app/views/admin/application_settings/_help_page.html.haml
+++ b/app/views/admin/application_settings/_help_page.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-help-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
= render_if_exists 'admin/application_settings/help_text_setting', form: f
diff --git a/app/views/admin/application_settings/_import_export_limits.html.haml b/app/views/admin/application_settings/_import_export_limits.html.haml
index 4e774dd0a1e..bc4a1577f90 100644
--- a/app/views/admin/application_settings/_import_export_limits.html.haml
+++ b/app/views/admin/application_settings/_import_export_limits.html.haml
@@ -1,5 +1,5 @@
= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-import-export-limits-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
= html_escape(_("Set any rate limit to %{code_open}0%{code_close} to disable the limit.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
diff --git a/app/views/admin/application_settings/_invitation_flow_enforcement.html.haml b/app/views/admin/application_settings/_invitation_flow_enforcement.html.haml
new file mode 100644
index 00000000000..895662b38fd
--- /dev/null
+++ b/app/views/admin/application_settings/_invitation_flow_enforcement.html.haml
@@ -0,0 +1,8 @@
+- return unless ::Feature.enabled?(:invitation_flow_enforcement_setting)
+
+- form = local_assigns.fetch(:form)
+
+%fieldset.form-group.gl-form-group
+ %legend.col-form-label.col-form-label
+ = s_("AdminSettings|Enforce invitation flow for groups and projects")
+ = form.gitlab_ui_checkbox_component :invitation_flow_enforcement, s_("AdminSettings|Users and groups must accept the invitation before they're added to a group or project.")
diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml
index 9a9038ef48e..4362ae9cb9b 100644
--- a/app/views/admin/application_settings/_ip_limits.html.haml
+++ b/app/views/admin/application_settings/_ip_limits.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-ip-limits-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
= _("Rate limits can help reduce request volume (like from crawlers or abusive bots).")
diff --git a/app/views/admin/application_settings/_issue_limits.html.haml b/app/views/admin/application_settings/_issue_limits.html.haml
index 64aca50cbe9..431e2a64c46 100644
--- a/app/views/admin/application_settings/_issue_limits.html.haml
+++ b/app/views/admin/application_settings/_issue_limits.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-issue-limits-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_jira_connect_application_key.html.haml b/app/views/admin/application_settings/_jira_connect_application_key.html.haml
index 68a82288573..e3df408cd4c 100644
--- a/app/views/admin/application_settings/_jira_connect_application_key.html.haml
+++ b/app/views/admin/application_settings/_jira_connect_application_key.html.haml
@@ -12,7 +12,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-jira-connect-application-id-settings'), html: { class: 'fieldset-form', id: 'jira-connect-application-id-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml
index c0ac924407f..4f5a313d7b7 100644
--- a/app/views/admin/application_settings/_kroki.html.haml
+++ b/app/views/admin/application_settings/_kroki.html.haml
@@ -10,7 +10,7 @@
= link_to _('Learn more.'), help_page_path('administration/integration/kroki.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-kroki-settings'), html: { class: 'fieldset-form', id: 'kroki-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true) if expanded
+ = form_errors(@application_setting) if expanded
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
index 0477f114bdf..a6ed48ef4fe 100644
--- a/app/views/admin/application_settings/_localization.html.haml
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-localization-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml
index cbe7e1c5bb6..1604419869c 100644
--- a/app/views/admin/application_settings/_mailgun.html.haml
+++ b/app/views/admin/application_settings/_mailgun.html.haml
@@ -9,7 +9,7 @@
= _('Configure the %{link} integration.').html_safe % { link: link_to(_('Mailgun events'), 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', target: '_blank', rel: 'noopener noreferrer') }
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-mailgun-settings'), html: { class: 'fieldset-form', id: 'mailgun-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true) if expanded
+ = form_errors(@application_setting) if expanded
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_network_rate_limits.html.haml b/app/views/admin/application_settings/_network_rate_limits.html.haml
index 173e830c7da..f1857a9749a 100644
--- a/app/views/admin/application_settings/_network_rate_limits.html.haml
+++ b/app/views/admin/application_settings/_network_rate_limits.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: anchor), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
= _("Rate limits can help reduce request volume (like from crawlers or abusive bots).")
diff --git a/app/views/admin/application_settings/_note_limits.html.haml b/app/views/admin/application_settings/_note_limits.html.haml
index b783345b9df..40760b3c45e 100644
--- a/app/views/admin/application_settings/_note_limits.html.haml
+++ b/app/views/admin/application_settings/_note_limits.html.haml
@@ -1,5 +1,5 @@
= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-note-limits-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml
index 2d91b777a0b..bacfe056683 100644
--- a/app/views/admin/application_settings/_outbound.html.haml
+++ b/app/views/admin/application_settings/_outbound.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-outbound-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
@@ -15,7 +15,7 @@
= f.text_area :outbound_local_requests_allowlist_raw, placeholder: "example.com, 192.168.1.1, xn--itlab-j1a.com", class: 'form-control gl-form-input', rows: 8
%span.form-text.text-muted
= s_('OutboundRequests|Requests to these domains and IP addresses are accessible to both system hooks and web hooks even when local requests are not allowed. IP ranges such as 1:0:0:0:0:0:0:0/124 and 127.0.0.0/28 are supported. Domain wildcards are not supported. To separate entries use commas, semicolons, or newlines. The allowlist can hold a maximum of 1000 entries. Domains must be IDNA encoded.')
- = link_to _('Learn more.'), help_page_path('security/webhooks.md', anchor: 'allowlist-for-local-requests'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('security/webhooks.md', anchor: 'create-an-allowlist-for-local-requests'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= f.gitlab_ui_checkbox_component :dns_rebinding_protection_enabled,
diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml
index b31576b5c48..4bdfa5bfe83 100644
--- a/app/views/admin/application_settings/_package_registry.html.haml
+++ b/app/views/admin/application_settings/_package_registry.html.haml
@@ -26,7 +26,7 @@
- @plans.each_with_index do |plan, index|
.tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' }
= form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-package-settings'), html: { class: 'fieldset-form' }, method: :post do |f|
- = form_errors(plan, pajamas_alert: true)
+ = form_errors(plan)
%fieldset
= f.hidden_field(:plan_id, value: plan.id)
.form-group
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index 23b0d2d2092..cf43d3ddeca 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-pages-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
@@ -22,6 +22,13 @@
- pages_link_url = help_page_path('administration/pages/index', anchor: 'set-maximum-size-of-gitlab-pages-site-in-a-project')
- pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url }
= s_('AdminSettings|Set the maximum size of GitLab Pages per project (0 for unlimited). %{link_start}Learn more.%{link_end}').html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe }
+ .form-group
+ = f.label :max_pages_custom_domains_per_project, s_('AdminSettings|Maximum number of custom domains per project'), class: 'label-bold'
+ = f.number_field :max_pages_custom_domains_per_project, class: 'form-control gl-form-input'
+ .form-text.text-muted
+ - pages_link_url = help_page_path('administration/pages/index', anchor: 'set-maximum-number-of-gitlab-pages-custom-domains-for-a-project')
+ - pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url }
+ = s_('AdminSettings|Set the maximum number of GitLab Pages custom domains per project (0 for unlimited). %{link_start}Learn more.%{link_end}').html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe }
%h5
= s_("AdminSettings|Configure Let's Encrypt")
%p
diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml
index c87d166f8d9..e0ba8d93fbd 100644
--- a/app/views/admin/application_settings/_performance.html.haml
+++ b/app/views/admin/application_settings/_performance.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-performance-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml
index a7f73edcf69..4e37c4c3c98 100644
--- a/app/views/admin/application_settings/_performance_bar.html.haml
+++ b/app/views/admin/application_settings/_performance_bar.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-performance-bar-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_pipeline_limits.html.haml b/app/views/admin/application_settings/_pipeline_limits.html.haml
index 3b33c41a924..e93823172db 100644
--- a/app/views/admin/application_settings/_pipeline_limits.html.haml
+++ b/app/views/admin/application_settings/_pipeline_limits.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-pipeline-limits-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml
index 8be37ff1dda..5c86ce8dbfb 100644
--- a/app/views/admin/application_settings/_plantuml.html.haml
+++ b/app/views/admin/application_settings/_plantuml.html.haml
@@ -10,7 +10,7 @@
= link_to _('Learn more.'), help_page_path('administration/integration/plantuml.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form', id: 'plantuml-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true) if expanded
+ = form_errors(@application_setting) if expanded
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml
index d8dffd6bc16..982531e9a2f 100644
--- a/app/views/admin/application_settings/_prometheus.html.haml
+++ b/app/views/admin/application_settings/_prometheus.html.haml
@@ -1,13 +1,13 @@
= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-prometheus-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
- prometheus_help_link_url = help_page_path('administration/monitoring/prometheus/gitlab_metrics')
- prometheus_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: prometheus_help_link_url }
= f.gitlab_ui_checkbox_component :prometheus_metrics_enabled,
- _('Enable health and performance metrics endpoint'),
- help_text: s_('AdminSettings|Enable a Prometheus endpoint that exposes health and performance statistics. The Health Check menu item appears in the Monitoring section of the Admin Area. Restart required. %{link_start}Learn more.%{link_end}').html_safe % { link_start: prometheus_help_link_start, link_end: '</a>'.html_safe }
+ _('Enable GitLab Prometheus metrics endpoint'),
+ help_text: s_('AdminSettings|Enable collection of application metrics. Restart required. %{link_start}Learn how to export metrics to Prometheus%{link_end}.').html_safe % { link_start: prometheus_help_link_start, link_end: '</a>'.html_safe }
.form-text.gl-text-gray-500.gl-pl-6
- unless Gitlab::Metrics.metrics_folder_present?
- icon_link = link_to sprite_icon('question-o'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml
index 00da0f59be4..1f3f67c71c7 100644
--- a/app/views/admin/application_settings/_protected_paths.html.haml
+++ b/app/views/admin/application_settings/_protected_paths.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-protected-paths-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml
index 66003f31104..6a7ec05d206 100644
--- a/app/views/admin/application_settings/_realtime.html.haml
+++ b/app/views/admin/application_settings/_realtime.html.haml
@@ -1,5 +1,5 @@
= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-realtime-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
index db4d1cb323c..856db32e088 100644
--- a/app/views/admin/application_settings/_registry.html.haml
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index 40d847f4949..ef8d3ccc8ab 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-check-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.sub-section
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
index 156a6bbcfa6..dad8d5f3fae 100644
--- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-mirror-settings') do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml
index a8e109ce377..d962d050ebc 100644
--- a/app/views/admin/application_settings/_repository_static_objects.html.haml
+++ b/app/views/admin/application_settings/_repository_static_objects.html.haml
@@ -1,5 +1,5 @@
= form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-static-objects-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index ff10e4a8f77..9e7f2812d64 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-storage-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.sub-section
diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml
index 7781db29bab..1d6051a06ea 100644
--- a/app/views/admin/application_settings/_runner_registrars_form.html.haml
+++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-runner-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.gl-form-group
diff --git a/app/views/admin/application_settings/_search_limits.html.haml b/app/views/admin/application_settings/_search_limits.html.haml
index 93637b59d60..945c9397f0d 100644
--- a/app/views/admin/application_settings/_search_limits.html.haml
+++ b/app/views/admin/application_settings/_search_limits.html.haml
@@ -1,5 +1,5 @@
= 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, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_sentry.html.haml b/app/views/admin/application_settings/_sentry.html.haml
index ece8f50151a..cfd34f6ca15 100644
--- a/app/views/admin/application_settings/_sentry.html.haml
+++ b/app/views/admin/application_settings/_sentry.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-sentry-settings'), html: { class: 'fieldset-form', id: 'sentry-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%span.text-muted
= _('Changing any setting here requires an application restart')
diff --git a/app/views/admin/application_settings/_sidekiq_job_limits.html.haml b/app/views/admin/application_settings/_sidekiq_job_limits.html.haml
index a28e6e62e7f..eaf4bbf4702 100644
--- a/app/views/admin/application_settings/_sidekiq_job_limits.html.haml
+++ b/app/views/admin/application_settings/_sidekiq_job_limits.html.haml
@@ -1,5 +1,5 @@
= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-sidekiq-job-limits-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 870bfbf4184..48f0b9b2c31 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-signin-settings'), html: { class: 'fieldset-form', id: 'signin-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
index 2365daa2c70..fccf039533b 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -1,3 +1,3 @@
-= form_errors(@application_setting, pajamas_alert: true)
+= form_errors(@application_setting)
#js-signup-form{ data: signup_form_data }
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index d500194b742..8684b909853 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -10,7 +10,7 @@
= html_escape(_('Configure %{link} to track events. %{link_start}Learn more.%{link_end}')) % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank', rel: 'noopener noreferrer').html_safe, link_start: link_start, link_end: '</a>'.html_safe }
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form', id: 'snowplow-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true) if expanded
+ = form_errors(@application_setting) if expanded
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml
index 43ff2bc02f5..9e99b496ad0 100644
--- a/app/views/admin/application_settings/_sourcegraph.html.haml
+++ b/app/views/admin/application_settings/_sourcegraph.html.haml
@@ -17,7 +17,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form', id: 'sourcegraph-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml
index 7f3125d91ba..bb512940be2 100644
--- a/app/views/admin/application_settings/_spam.html.haml
+++ b/app/views/admin/application_settings/_spam.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-spam-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
%h5
diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml
index 5703fbb463e..c53f63e124b 100644
--- a/app/views/admin/application_settings/_terminal.html.haml
+++ b/app/views/admin/application_settings/_terminal.html.haml
@@ -1,5 +1,5 @@
= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terminal-settings'), html: { class: 'fieldset-form', id: 'terminal-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml
index c5387db59ef..a4b6e061c43 100644
--- a/app/views/admin/application_settings/_terms.html.haml
+++ b/app/views/admin/application_settings/_terms.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terms-settings'), html: { class: 'fieldset-form', id: 'terms-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml
index 397b47eefaa..20a60ac870a 100644
--- a/app/views/admin/application_settings/_third_party_offers.html.haml
+++ b/app/views/admin/application_settings/_third_party_offers.html.haml
@@ -9,7 +9,7 @@
= _('Control whether to display customer experience improvement content and third-party offers in GitLab.')
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form', id: 'third-party-offers-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true) if expanded
+ = form_errors(@application_setting) if expanded
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index 7326a63f8c2..046b59dbd18 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -3,7 +3,7 @@
- link_end = '</a>'.html_safe
= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_users_api_limits.html.haml b/app/views/admin/application_settings/_users_api_limits.html.haml
index f2edb81141d..3918c76b12c 100644
--- a/app/views/admin/application_settings/_users_api_limits.html.haml
+++ b/app/views/admin/application_settings/_users_api_limits.html.haml
@@ -1,5 +1,5 @@
= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-users-api-limits-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
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 d35fba7d3b2..b69b2f74d0d 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-visibility-settings'), html: { class: 'fieldset-form', id: 'visibility-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
= render 'shared/project_creation_levels', f: f, method: :default_project_creation, legend: s_('ProjectCreationLevel|Default project creation protection')
diff --git a/app/views/admin/application_settings/_whats_new.html.haml b/app/views/admin/application_settings/_whats_new.html.haml
index d82bb1c94e4..3248969ca16 100644
--- a/app/views/admin/application_settings/_whats_new.html.haml
+++ b/app/views/admin/application_settings/_whats_new.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-whats-new-settings'), html: { class: 'fieldset-form whats-new-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
- whats_new_variants.each_key do |variant|
.gl-mb-4
diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml
index 349e1dfde5d..a3bd8b52148 100644
--- a/app/views/admin/application_settings/appearances/_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_form.html.haml
@@ -1,7 +1,7 @@
- parsed_with_gfm = (_("Content parsed with %{link}.") % { link: link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank') }).html_safe
= gitlab_ui_form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f|
- = form_errors(@appearance, pajamas_alert: true)
+ = form_errors(@appearance)
.row
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index d7559fcd48b..cd63873a893 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -13,7 +13,7 @@
.settings-content
= render 'visibility_and_access'
-%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'account_and_limit_settings_content' } }
+%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'account_and_limit_settings_content', testid: 'account-limit' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Account and limit')
@@ -94,7 +94,7 @@
= _('Manage Web IDE features.')
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: "js-web-ide-settings"), html: { class: 'fieldset-form', id: 'web-ide-settings' } do |f|
- = form_errors(@application_setting, pajamas_alert: true)
+ = form_errors(@application_setting)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index d4476bf838a..b79b189e9cf 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -11,7 +11,7 @@
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Monitor the health and performance of GitLab with Prometheus.')
+ = _('Monitor GitLab with Prometheus.')
.settings-content
= render 'prometheus'
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index e0926221bcc..fd73d4c5671 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for [:admin, @application], url: @url, html: {role: 'form'} do |f|
- = form_errors(application, pajamas_alert: true)
+ = form_errors(application)
= content_tag :div, class: 'form-group row' do
.col-sm-2.col-form-label
diff --git a/app/views/admin/background_migrations/index.html.haml b/app/views/admin/background_migrations/index.html.haml
index c8b195219ec..0f76fdce416 100644
--- a/app/views/admin/background_migrations/index.html.haml
+++ b/app/views/admin/background_migrations/index.html.haml
@@ -5,7 +5,7 @@
.gl-flex-grow-1
%h3= s_('BackgroundMigrations|Background Migrations')
%p.light.gl-mb-0
- - learnmore_link = help_page_path('development/database/batched_background_migrations')
+ - learnmore_link = help_page_path('user/admin_area/monitoring/background_migrations')
- learnmore_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: learnmore_link }
= html_escape(s_('BackgroundMigrations|Background migrations are used to perform data migrations whenever a migration exceeds the time limits in our guidelines. %{linkStart}Learn more%{linkEnd}')) % { linkStart: learnmore_link_start, linkEnd: '</a>'.html_safe }
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 865b60a74b8..dfd3b87c674 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -2,7 +2,7 @@
= render 'preview'
= gitlab_ui_form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f|
- = form_errors(@broadcast_message, pajamas_alert: true)
+ = form_errors(@broadcast_message)
.form-group.row.mt-4
.col-sm-2.col-form-label
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index a254690de72..69e9e4260b4 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -1,42 +1,34 @@
= gitlab_ui_form_for [:admin, @group] do |f|
- = form_errors(@group, pajamas_alert: true)
- .gl-border-b.gl-mb-6
- .row
- .col-lg-4
- %h4.gl-mt-0
- = _('Naming, visibility')
- %p
- = _('Update your group name, description, avatar, and visibility.')
- = link_to _('Learn more about groups.'), help_page_path('user/group/index')
- .col-lg-8
- = render 'shared/groups/group_name_and_path_fields', f: f
- = render 'shared/group_form_description', f: f
- .form-group.gl-form-group{ role: 'group' }
- = f.label :avatar, _("Group avatar"), class: 'gl-display-block col-form-label'
- = render 'shared/choose_avatar_button', f: f
- = render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false
+ = form_errors(@group)
+ = render ::Layouts::HorizontalSectionComponent.new(options: { class: 'gl-mb-6' }) do |c|
+ = c.title { _('Naming, visibility') }
+ = c.description do
+ = _('Update your group name, description, avatar, and visibility.')
+ = link_to _('Learn more about groups.'), help_page_path('user/group/index')
+ = c.body do
+ = render 'shared/groups/group_name_and_path_fields', f: f
+ = render 'shared/group_form_description', f: f
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :avatar, _("Group avatar"), class: 'gl-display-block col-form-label'
+ = render 'shared/choose_avatar_button', f: f
+ = render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false
- .gl-border-b.gl-pb-3.gl-mb-6
- .row
- .col-lg-4
- %h4.gl-mt-0
- = _('Permissions and group features')
- %p
- = _('Configure advanced permissions, Large File Storage, two-factor authentication, and CI/CD settings.')
- .col-lg-8
- = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group
- = render_if_exists 'admin/namespace_plan', f: f
- .form-group.gl-form-group{ role: 'group' }
- = render 'shared/allow_request_access', form: f
- = render 'groups/group_admin_settings', f: f
- = render_if_exists 'namespaces/shared_runners_minutes_settings', group: @group, form: f
- .gl-mb-3
- .row
- .col-lg-4
- %h4.gl-mt-0
- = _('Admin notes')
- .col-lg-8
- = render 'shared/admin/admin_note_form', f: f
+ = render ::Layouts::HorizontalSectionComponent.new(options: { class: 'gl-pb-3 gl-mb-6' }) do |c|
+ = c.title { _('Permissions and group features') }
+ = c.description do
+ = _('Configure advanced permissions, Large File Storage, two-factor authentication, and CI/CD settings.')
+ = c.body do
+ = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group
+ = render_if_exists 'admin/namespace_plan', f: f
+ .form-group.gl-form-group{ role: 'group' }
+ = render 'shared/allow_request_access', form: f
+ = render 'groups/group_admin_settings', f: f
+ = render_if_exists 'namespaces/shared_runners_minutes_settings', group: @group, form: f
+
+ = render ::Layouts::HorizontalSectionComponent.new(border: false, options: { class: 'gl-pb-3' }) do |c|
+ = c.title { _('Admin notes') }
+ = c.body do
+ = render 'shared/admin/admin_note_form', f: f
- if @group.new_record?
= render Pajamas::AlertComponent.new(dismissible: false) do |c|
diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
index cf3b6e6e0e0..a309e874317 100644
--- a/app/views/admin/hooks/_form.html.haml
+++ b/app/views/admin/hooks/_form.html.haml
@@ -1,4 +1,4 @@
-= form_errors(hook, pajamas_alert: true)
+= form_errors(hook)
.form-group
= form.label :url, _('URL'), class: 'label-bold'
diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml
index 40c4d292e9d..ba7687db9c7 100644
--- a/app/views/admin/identities/_form.html.haml
+++ b/app/views/admin/identities/_form.html.haml
@@ -1,5 +1,5 @@
= form_for [:admin, @user, @identity], html: { class: 'fieldset-form' } do |f|
- = form_errors(@identity, pajamas_alert: true)
+ = form_errors(@identity)
.form-group.row
.col-sm-2.col-form-label
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 6921c051361..eabb7e51227 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -23,119 +23,120 @@
= last_check_message.html_safe
.row
.col-md-6
- .card
- .card-header
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c|
+ - c.header do
= _('Project info:')
- %ul.content-list
- %li
- %span.light
- = _('Name:')
- %strong
- = link_to @project.name, project_path(@project)
- %li
- %span.light
- = _('Namespace:')
- %strong
- - if @project.namespace
- = link_to @project.namespace.human_name, [:admin, @project.personal? ? @project.namespace.owner : @project.group]
- - else
- = s_('ProjectSettings|Global')
- %li
- %span.light
- = _('Owned by:')
- %strong
- - if @project.owners.any?
- = safe_join(@project.owners.map { |owner| link_to(owner.name, [:admin, owner]) }, ", ".html_safe)
- - else
- = _('(deleted)')
-
- %li
- %span.light
- = _('Created by:')
- %strong
- = @project.creator.try(:name) || _('(deleted)')
-
- %li
- %span.light
- = _('Created on:')
- %strong
- = @project.created_at.to_s(:medium)
-
- %li
- %span.light
- = _('ID:')
- %strong
- = @project.id
-
- %li
- %span.light
- = _('http:')
- %strong
- = link_to @project.http_url_to_repo, project_path(@project)
- %li
- %span.light
- = _('ssh:')
- %strong
- = link_to @project.ssh_url_to_repo, project_path(@project)
- - if @project.repository.exists?
- %li
+ - c.body do
+ %ul.content-list
+ %li{ class: 'gl-px-5!' }
%span.light
- = _('Gitaly storage name:')
+ = _('Name:')
%strong
- = @project.repository.storage
- %li
+ = link_to @project.name, project_path(@project)
+ %li{ class: 'gl-px-5!' }
%span.light
- = _('Gitaly relative path:')
+ = _('Namespace:')
%strong
- = @project.repository.relative_path
-
- %li
- = render 'shared/storage_counter_statistics', storage_size: @project.statistics&.storage_size, storage_details: @project.statistics
-
- %li
+ - if @project.namespace
+ = link_to @project.namespace.human_name, [:admin, @project.personal? ? @project.namespace.owner : @project.group]
+ - else
+ = s_('ProjectSettings|Global')
+ %li{ class: 'gl-px-5!' }
%span.light
- = _('last commit:')
+ = _('Owned by:')
%strong
- = last_commit(@project)
+ - if @project.owners.any?
+ = safe_join(@project.owners.map { |owner| link_to(owner.name, [:admin, owner]) }, ", ".html_safe)
+ - else
+ = _('(deleted)')
- %li
+ %li{ class: 'gl-px-5!' }
%span.light
- = _('Git LFS status:')
+ = _('Created by:')
%strong
- = project_lfs_status(@project)
- = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index')
- - else
- %li
- %span.light
- = _('repository:')
- %strong.cred
- = _('does not exist')
+ = @project.creator.try(:name) || _('(deleted)')
- - if @project.archived?
- %li
+ %li{ class: 'gl-px-5!' }
%span.light
- = _('archived:')
+ = _('Created on:')
%strong
- = _('project is read-only')
+ = @project.created_at.to_s(:medium)
- = render_if_exists "shared_runner_status", project: @project
+ %li{ class: 'gl-px-5!' }
+ %span.light
+ = _('ID:')
+ %strong
+ = @project.id
- %li
- %span.light
- = _('access:')
- %strong
- %span{ class: visibility_level_color(@project.visibility_level) }
- = visibility_level_icon(@project.visibility_level)
- = visibility_level_label(@project.visibility_level)
+ %li{ class: 'gl-px-5!' }
+ %span.light
+ = _('http:')
+ %strong
+ = link_to @project.http_url_to_repo, project_path(@project)
+ %li{ class: 'gl-px-5!' }
+ %span.light
+ = _('ssh:')
+ %strong
+ = link_to @project.ssh_url_to_repo, project_path(@project)
+ - if @project.repository.exists?
+ %li{ class: 'gl-px-5!' }
+ %span.light
+ = _('Gitaly storage name:')
+ %strong
+ = @project.repository.storage
+ %li{ class: 'gl-px-5!' }
+ %span.light
+ = _('Gitaly relative path:')
+ %strong
+ = @project.repository.relative_path
+
+ %li{ class: 'gl-px-5!' }
+ = render 'shared/storage_counter_statistics', storage_size: @project.statistics&.storage_size, storage_details: @project.statistics
+
+ %li{ class: 'gl-px-5!' }
+ %span.light
+ = _('last commit:')
+ %strong
+ = last_commit(@project)
+
+ %li{ class: 'gl-px-5!' }
+ %span.light
+ = _('Git LFS status:')
+ %strong
+ = project_lfs_status(@project)
+ = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index')
+ - else
+ %li{ class: 'gl-px-5!' }
+ %span.light
+ = _('repository:')
+ %strong.cred
+ = _('does not exist')
+
+ - if @project.archived?
+ %li{ class: 'gl-px-5!' }
+ %span.light
+ = _('archived:')
+ %strong
+ = _('project is read-only')
+
+ = render_if_exists "admin/projects/shared_runner_status", project: @project
+
+ %li{ class: 'gl-px-5!' }
+ %span.light
+ = _('access:')
+ %strong
+ %span{ class: visibility_level_color(@project.visibility_level) }
+ = visibility_level_icon(@project.visibility_level)
+ = visibility_level_label(@project.visibility_level)
= render 'shared/custom_attributes', custom_attributes: @project.custom_attributes
= render_if_exists 'admin/projects/geo_status_widget', locals: { project: @project }
- .card
- .card-header
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c|
+ - c.header do
= s_('ProjectSettings|Transfer project')
- .card-body
+ - c.body do
= form_for @project, url: transfer_admin_project_path(@project), method: :put do |f|
.form-group.row
.col-sm-3.col-form-label
@@ -150,10 +151,10 @@
.offset-sm-3.col-sm-9
= f.submit _('Transfer'), class: 'gl-button btn btn-confirm'
- .card.repository-check
- .card-header
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5 repository-check' }) do |c|
+ - c.header do
= _("Repository check")
- .card-body
+ - c.body do
= form_for @project, url: repository_check_admin_project_path(@project), method: :post do |f|
.form-group
- if @project.last_repository_check_at.nil?
@@ -172,34 +173,36 @@
.col-md-6
- if @group
- .card
- .card-header
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }, footer_options: { class: 'gl-p-4' }) do |c|
+ - c.header do
%strong= @group.name
= _('group members')
= gl_badge_tag @group_members.size
= render 'shared/members/manage_access_button', path: group_group_members_path(@group)
- %ul.content-list.members-list
- = render partial: 'shared/members/member',
- collection: @group_members, as: :member,
- locals: { membership_source: @project,
- group: @group,
- current_user_is_group_owner: current_user_is_group_owner }
- .card-footer
+ - c.body do
+ %ul.content-list.members-list
+ = render partial: 'shared/members/member',
+ collection: @group_members, as: :member,
+ locals: { membership_source: @project,
+ group: @group,
+ current_user_is_group_owner: current_user_is_group_owner }
+ - c.footer do
= paginate @group_members, param_name: 'group_members_page', theme: 'gitlab'
= render 'shared/members/requests', membership_source: @project, group: @group, requesters: @requesters
- .card
- .card-header
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }, footer_options: { class: 'gl-p-4' }) do |c|
+ - c.header do
%strong= @project.name
= _('project members')
= gl_badge_tag @project.users.size
= render 'shared/members/manage_access_button', path: project_project_members_path(@project)
- %ul.content-list.project_members.members-list
- = render partial: 'shared/members/member',
- collection: @project_members, as: :member,
- locals: { membership_source: @project,
- group: @group,
- current_user_is_group_owner: current_user_is_group_owner }
- .card-footer
+ - c.body do
+ %ul.content-list.project_members.members-list
+ = render partial: 'shared/members/member',
+ collection: @project_members, as: :member,
+ locals: { membership_source: @project,
+ group: @group,
+ current_user_is_group_owner: current_user_is_group_owner }
+ - c.footer do
= paginate @project_members, param_name: 'project_members_page', theme: 'gitlab'
diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml
index 65eb1358b40..b755b4a442c 100644
--- a/app/views/admin/sessions/_new_base.html.haml
+++ b/app/views/admin/sessions/_new_base.html.haml
@@ -1,7 +1,7 @@
= form_tag(admin_session_path, method: :post, class: 'new_user gl-show-field-errors', 'aria-live': 'assertive') do
.form-group
= label_tag :user_password, _('Password'), class: 'label-bold'
- = password_field_tag 'user[password]', nil, class: 'form-control', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
+ = password_field_tag 'user[password]', nil, class: 'form-control', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field', testid: 'password-field' }
.submit-container.move-submit-down
= submit_tag _('Enter Admin Mode'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'enter_admin_mode_button' }
diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml
index 9372bae14c3..c7382266480 100644
--- a/app/views/admin/sessions/_signin_box.html.haml
+++ b/app/views/admin/sessions/_signin_box.html.haml
@@ -4,8 +4,6 @@
.login-body
= render 'devise/sessions/new_crowd'
- = render_if_exists 'devise/sessions/new_kerberos_tab'
-
- ldap_servers.each_with_index do |server, i|
.login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain)) }
.login-body
diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml
index cd6df5f30f3..2d0ea585735 100644
--- a/app/views/admin/spam_logs/_spam_log.html.haml
+++ b/app/views/admin/spam_logs/_spam_log.html.haml
@@ -26,11 +26,13 @@
= 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 }, 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
- = _("Submitted as ham")
- - else
- = link_to _('Submit as ham'), mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'gl-button btn btn-default btn-sm gl-mb-3'
+ -# TODO: Remove conditonal once spamcheck supports this https://gitlab.com/gitlab-com/gl-security/engineering-and-research/automation-team/spam/spamcheck/-/issues/190
+ - if akismet_enabled?
+ - if spam_log.submitted_as_ham?
+ .gl-button.btn.btn-default.btn-sm.disabled.gl-mb-3
+ = _("Submitted as ham")
+ - else
+ = link_to _('Submit as ham'), mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'gl-button btn btn-default btn-sm gl-mb-3'
- if user && !user.blocked?
= link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-default btn-sm gl-mb-3"
- else
diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml
index 1c1bc61aef2..9b9d97950cc 100644
--- a/app/views/admin/topics/_form.html.haml
+++ b/app/views/admin/topics/_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @topic, url: url, html: { multipart: true, class: 'js-project-topic-form gl-show-field-errors common-note-form js-quick-submit js-requires-input' }, authenticity_token: true do |f|
- = form_errors(@topic, pajamas_alert: true)
+ = form_errors(@topic)
.form-group
= f.label :name do
diff --git a/app/views/admin/topics/index.html.haml b/app/views/admin/topics/index.html.haml
index 6485b8aa411..77823ed7058 100644
--- a/app/views/admin/topics/index.html.haml
+++ b/app/views/admin/topics/index.html.haml
@@ -1,16 +1,16 @@
- page_title _("Topics")
-= form_tag admin_topics_path, method: :get do |f|
- .gl-py-3.gl-display-flex.gl-flex-direction-column-reverse.gl-md-flex-direction-row.gl-border-b-solid.gl-border-gray-100.gl-border-b-1
- .gl-flex-grow-1.gl-mt-3.gl-md-mt-0
- .inline.gl-w-full.gl-md-w-auto
- - search = params.fetch(:search, nil)
- .search-field-holder
- = search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name'), data: { qa_selector: 'topic_search_field' }
- = sprite_icon('search', css_class: 'search-icon')
- .nav-controls
- = link_to new_admin_topic_path, class: "gl-button btn btn-confirm gl-w-full gl-md-w-auto" do
- = _('New topic')
+.top-area
+ .nav-controls.gl-w-full.gl-mt-3.gl-mb-3
+ = form_tag admin_topics_path, method: :get do |f|
+ - search = params.fetch(:search, nil)
+ .search-field-holder
+ = search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name'), data: { qa_selector: 'topic_search_field' }
+ = sprite_icon('search', css_class: 'search-icon')
+ .gl-flex-grow-1
+ .js-merge-topics{ data: { path: merge_admin_topics_path } }
+ = link_to new_admin_topic_path, class: "gl-button btn btn-confirm gl-w-full gl-md-w-auto" do
+ = _('New topic')
%ul.content-list
= render partial: 'topic', collection: @topics
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 5ac15694922..47a761e608f 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -1,6 +1,6 @@
.user_new
= gitlab_ui_form_for [:admin, @user], html: { class: 'fieldset-form' } do |f|
- = form_errors(@user, pajamas_alert: true)
+ = form_errors(@user)
.gl-border-b.gl-pb-3.gl-mb-6
.row
diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml
index 6cf414dc648..6ed46847482 100644
--- a/app/views/award_emoji/_awards_block.html.haml
+++ b/app/views/award_emoji/_awards_block.html.haml
@@ -1,7 +1,7 @@
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
- if api_awards_path
- .gl-display-flex.gl-flex-wrap
+ .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between
#js-vue-awards-block{ data: { path: api_awards_path, can_award_emoji: can?(current_user, :award_emoji, awardable).to_s } }
= yield
- else
diff --git a/app/views/clusters/clusters/_gitlab_integration_form.html.haml b/app/views/clusters/clusters/_gitlab_integration_form.html.haml
index e0f5a984529..b6d6dcdd7a9 100644
--- a/app/views/clusters/clusters/_gitlab_integration_form.html.haml
+++ b/app/views/clusters/clusters/_gitlab_integration_form.html.haml
@@ -1,3 +1,3 @@
= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'js-cluster-details-form' } do |field|
- = form_errors(@cluster, pajamas_alert: true)
+ = form_errors(@cluster)
#js-cluster-details-form{ data: js_cluster_form_data(@cluster, can?(current_user, :update_cluster, @cluster)) }
diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml
index ed6cecdcc3d..4edb0f324dc 100644
--- a/app/views/dashboard/_activities.html.haml
+++ b/app/views/dashboard/_activities.html.haml
@@ -2,7 +2,7 @@
= render 'shared/event_filter'
.controls
= link_to dashboard_projects_path(rss_url_options), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip', title: 'Subscribe' do
- = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon')
+ = sprite_icon('rss', css_class: 'gl-icon')
.content_list
.loading
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index 39fbd9bc097..bc8e3e6ab69 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -9,7 +9,7 @@
- if current_user
.page-title-controls
= render 'shared/new_project_item_select',
- path: '-/milestones/new', label: 'New milestone',
+ path: '-/milestones/new', label: _('Milestone'),
include_groups: true, type: :milestones
- if @milestone_states.any? { |name, count| count > 0 }
@@ -23,7 +23,7 @@
- if current_user
.page-title-controls
= render 'shared/new_project_item_select',
- path: '-/milestones/new', label: 'New milestone',
+ path: '-/milestones/new', label: _('Milestone'),
include_groups: true, type: :milestones
- else
.milestones
@@ -36,5 +36,5 @@
- if current_user
.page-title-controls
= render 'shared/new_project_item_select',
- path: '-/milestones/new', label: 'New milestone',
+ path: '-/milestones/new', label: _('Milestone'),
include_groups: true, type: :milestones
diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml
index 0658d548eab..a9a34af3f96 100644
--- a/app/views/dashboard/projects/_blank_state_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml
@@ -11,14 +11,9 @@
%p
= _('Projects are where you store your code, access issues, wiki and other features of GitLab.')
- else
- .blank-state.gl-display-flex.gl-align-items-center.gl-border-1.gl-border-solid.gl-border-gray-100.gl-rounded-base.gl-mb-5
- .blank-state-icon
- = custom_icon("add_new_project", size: 50)
- .blank-state-body.gl-sm-pl-0.gl-pl-6
- %h3.gl-font-size-h2.gl-mt-0
- = _('Create a project')
- %p
- = _('If you are added to a project, it will be displayed here.')
+ = render Pajamas::AlertComponent.new(variant: :info, alert_options: { class: 'gl-mb-5 gl-w-full' }) do |c|
+ = c.body do
+ = _("You see projects here when you're added to a group or project.").html_safe
- if current_user.can_create_group?
= link_to new_group_path, class: link_classes do
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index 8d82116bf10..b4668b1e52a 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -1,4 +1,4 @@
-%li.todo{ class: "todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data: { url: todo_target_path(todo) } }
+%li.todo.gl-hover-border-blue-200.gl-hover-bg-blue-50.gl-hover-cursor-pointer{ class: "todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data: { url: todo_target_path(todo) } }
.gl-display-flex.gl-flex-direction-row
.todo-avatar.gl-display-none.gl-sm-display-inline-block
= author_avatar(todo, size: 40)
@@ -49,13 +49,13 @@
.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
+ = link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button gl-bg-gray-50 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')
- = 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
+ = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button gl-bg-gray-50 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')
- 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
+ = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button gl-bg-gray-50 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')
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 6bfe18fd3b2..deb1ac9e360 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -93,7 +93,7 @@
.text-content.gl-text-center
- if todos_filter_empty?
%h4
- = Gitlab.config.gitlab.no_todos_messages.sample
+ = no_todos_messages.sample
%p
= (s_("Todos|Are you looking for things to do? Take a look at %{strongStart}%{openIssuesLinkStart}open issues%{openIssuesLinkEnd}%{strongEnd}, contribute to %{strongStart}%{mergeRequestLinkStart}a merge request%{mergeRequestLinkEnd}%{mergeRequestLinkEnd}%{strongEnd}, or mention someone in a comment to automatically assign them a new to-do item.") % { strongStart: '<strong>', strongEnd: '</strong>', openIssuesLinkStart: "<a href=\"#{issues_dashboard_path}\">", openIssuesLinkEnd: '</a>', mergeRequestLinkStart: "<a href=\"#{merge_requests_dashboard_path}\">", mergeRequestLinkEnd: '</a>' }).html_safe
- else
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 5a322a8f89b..3aeb89979bb 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -1,10 +1,10 @@
-= gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors js-sign-in-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f|
+= gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f|
.form-group.gl-px-5.gl-pt-5
= render_if_exists 'devise/sessions/new_base_user_login_label', form: f
= f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' }
.form-group.gl-px-5
= f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
- = f.password_field :password, class: 'form-control gl-form-input bottom', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
+ = f.password_field :password, class: 'form-control gl-form-input bottom', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field', testid: 'password-field' }
- if devise_mapping.rememberable?
.gl-px-5
.gl-display-inline-block
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index f4db9ea5637..e0e0b82b596 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -17,10 +17,15 @@
%div
= _('No authentication methods configured.')
+ - if Feature.enabled?(:restyle_login_page, @project)
+ %p.gl-px-5
+ = html_escape(s_("SignUp|By signing in you accept the %{link_start}Terms of Use and acknowledge the Privacy Policy and Cookie Policy%{link_end}.")) % { link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe,
+ link_end: '</a>'.html_safe }
+
- if allow_signup?
%p{ class: "gl-mt-3 #{'gl-text-center' if Feature.enabled?(:restyle_login_page, @project)}" }
= _("Don't have an account yet?")
- = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' }, class: "#{'gl-font-weight-bold' if Feature.enabled?(:restyle_login_page, @project)} "
+ = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' }
- if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled?
.clearfix
= render 'devise/shared/omniauth_box'
diff --git a/app/views/devise/sessions/successful_verification.haml b/app/views/devise/sessions/successful_verification.haml
index 8af80fbdceb..59280cc13ca 100644
--- a/app/views/devise/sessions/successful_verification.haml
+++ b/app/views/devise/sessions/successful_verification.haml
@@ -8,4 +8,4 @@
%p.gl-pt-2
- redirect_url_start = '<a href="%{url}"">'.html_safe % { url: @redirect_url }
- redirect_url_end = '</a>'.html_safe
- = html_escape(s_("IdentityVerification|Your account has been successfully verified. You'll be redirected to your account in just a moment or %{redirect_url_start}click here%{redirect_url_end} to refresh.")) % { redirect_url_start: redirect_url_start, redirect_url_end: redirect_url_end }
+ = html_escape(s_("IdentityVerification|Your account has been successfully verified. You'll be redirected to your account in just a moment. You can also %{redirect_url_start}refresh the page%{redirect_url_end}.")) % { redirect_url_start: redirect_url_start, redirect_url_end: redirect_url_end }
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index d67669352a6..d4f34a1cb3f 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -1,19 +1,20 @@
- hide_remember_me = local_assigns.fetch(:hide_remember_me, false)
-%div{ class: Feature.enabled?(:restyle_login_page, @project) ? 'omniauth-container gl-mt-5 gl-p-5 gl-text-center gl-w-90p gl-ml-auto gl-mr-auto' : 'omniauth-container gl-mt-5 gl-p-5' }
- %label{ class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-font-weight-normal' : 'gl-font-weight-bold' }
+- restyle_login_page_enabled = Feature.enabled?(:restyle_login_page, @project)
+%div{ class: restyle_login_page_enabled ? 'omniauth-container gl-mt-5 gl-p-5 gl-text-center gl-w-90p gl-ml-auto gl-mr-auto' : 'omniauth-container gl-mt-5 gl-p-5' }
+ %label{ class: restyle_login_page_enabled ? 'gl-font-weight-normal' : 'gl-font-weight-bold' }
= _('Sign in with')
- providers = enabled_button_based_providers
- .gl-display-flex.gl-justify-content-between.gl-flex-wrap
+ .gl-display-flex.gl-flex-wrap{ class: restyle_login_page_enabled ? 'gl-justify-content-center' : 'gl-justify-content-between' }
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)} #{'gl-w-full' if Feature.disabled?(:restyle_login_page, @project)}", form: { class: 'gl-w-full gl-mb-3' } do
+ = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)} #{'gl-w-full' unless restyle_login_page_enabled}", form: { class: restyle_login_page_enabled ? 'gl-mb-3' : 'gl-w-full gl-mb-3' } do
- if has_icon
= provider_image_tag(provider)
%span.gl-button-text
= label_for_provider(provider)
- unless hide_remember_me
%fieldset
- %label{ class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-font-weight-normal' : '' }
+ %label{ class: restyle_login_page_enabled ? 'gl-font-weight-normal' : '' }
= check_box_tag :remember_me, nil, false
%span
= _('Remember me')
diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml
index ff93449194a..60f1ff02e76 100644
--- a/app/views/devise/shared/_signin_box.html.haml
+++ b/app/views/devise/shared/_signin_box.html.haml
@@ -4,8 +4,6 @@
.login-body
= render 'devise/sessions/new_crowd'
- = render_if_exists 'devise/sessions/new_kerberos_tab'
-
- ldap_servers.each_with_index do |server, i|
.login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain)) }
.login-body
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 991af1eea0c..b9c9c99bf1a 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -6,7 +6,7 @@
- if show_omniauth_providers && omniauth_providers_placement == :top
= render 'devise/shared/signup_omniauth_providers_top'
- = form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f|
+ = form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f|
.devise-errors
= render 'devise/shared/error_messages', resource: resource
- if Gitlab::CurrentSettings.invisible_captcha_enabled
@@ -66,8 +66,12 @@
= render_if_exists 'shared/password_requirements_list'
= render_if_exists 'devise/shared/phone_verification', form: f
%div
- - if show_recaptcha_sign_up?
+
+ - if Feature.enabled?(:arkose_labs_signup_challenge)
+ = render_if_exists 'devise/registrations/arkose_labs'
+ - elsif show_recaptcha_sign_up?
= recaptcha_tags nonce: content_security_policy_nonce
+
.submit-container.gl-mt-5
= f.submit button_text, class: 'btn gl-button btn-confirm gl-display-block gl-w-full', data: { qa_selector: 'new_user_register_button' }
- if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project)
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
index 8dc22674243..5c085555872 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_list.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -2,7 +2,7 @@
- if Feature.enabled?(:restyle_login_page, @project)
.gl-text-center.gl-pt-5
%label.gl-font-weight-normal
- = _("Create an account using:")
+ = _("Register with:")
.gl-text-center.gl-w-90p.gl-ml-auto.gl-mr-auto
- providers.each do |provider|
= link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do
diff --git a/app/views/devise/shared/_tab_single.html.haml b/app/views/devise/shared/_tab_single.html.haml
index 336954d00b0..b7ba8870df5 100644
--- a/app/views/devise/shared/_tab_single.html.haml
+++ b/app/views/devise/shared/_tab_single.html.haml
@@ -1,2 +1,2 @@
= gl_tabs_nav({ class: 'new-session-tabs gl-border-0' }) do
- = gl_tab_link_to tab_title, '#', { item_active: true, class: 'gl-cursor-default!', tab_class: 'gl-bg-transparent!', tabindex: '-1', data: { qa_selector: 'sign_in_tab' } }
+ = gl_tab_link_to tab_title, '#', { item_active: true, class: 'gl-cursor-default!', tab_class: 'gl-bg-transparent!', tabindex: '-1', data: { qa_selector: 'sign_in_tab', testid: 'sign-in-tab' } }
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index 0ef4a30d820..e81a5928983 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -1,14 +1,14 @@
- show_password_form = local_assigns.fetch(:show_password_form, password_authentication_enabled_for_web?)
- render_signup_link = local_assigns.fetch(:render_signup_link, true)
-%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: ('custom-provider-tabs' if any_form_based_providers_enabled?) }
+%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: "#{"custom-provider-tabs" if any_form_based_providers_enabled?} #{"nav-links-unboxed" if Feature.enabled?(:restyle_login_page, @project)}" }
- if crowd_enabled?
%li.nav-item
= link_to _("Crowd"), "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab', role: 'tab'
= render_if_exists "devise/shared/kerberos_tab"
- ldap_servers.each_with_index do |server, i|
%li.nav-item
- = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab' }, role: 'tab'
+ = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab', testid: 'ldap-tab' }, role: 'tab'
= render_if_exists 'devise/shared/tab_smartcard'
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 1c6dc1f2d5d..c19d64e789d 100644
--- a/app/views/devise/shared/_terms_of_service_notice.html.haml
+++ b/app/views/devise/shared/_terms_of_service_notice.html.haml
@@ -1,9 +1,17 @@
- return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms?
%p.gl-text-gray-500.gl-mt-5.gl-mb-0
- - 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 }
+ - if Feature.enabled?(:restyle_login_page, @project)
+ - if Gitlab.com?
+ = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the GitLab%{link_start} Terms of Use and acknowledge the Privacy Policy and Cookie 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
+ = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the%{link_start} Terms of Use and acknowledge the Privacy Policy and Cookie 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
- = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the %{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 }
+ - 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
+ = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the %{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 }
diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml
index 757c0a836f3..0d6c3e74ce8 100644
--- a/app/views/groups/_activities.html.haml
+++ b/app/views/groups/_activities.html.haml
@@ -2,7 +2,7 @@
= render 'shared/event_filter', show_group_events: @group.supports_events?
.controls
= link_to group_path(@group, rss_url_options), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' , title: _('Subscribe') do
- = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon')
+ = sprite_icon('rss', css_class: 'gl-icon')
.content_list
.loading
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 2911e9991f2..a82a2e41508 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -3,17 +3,17 @@
- emails_disabled = @group.emails_disabled?
.group-home-panel
- .row.mb-3
+ .row.my-3
.home-panel-title-row.col-md-12.col-lg-6.d-flex
.avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.float-none{ class: 'gl-mr-3!' }
= group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo')
.d-flex.flex-column.flex-wrap.align-items-baseline
.d-inline-flex.align-items-baseline
- %h1.home-panel-title.gl-mt-3.gl-mb-2{ itemprop: 'name' }
+ %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2{ itemprop: 'name' }
= @group.name
- %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
+ %span.visibility-icon.gl-text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
= visibility_level_icon(@group.visibility_level, options: {class: 'icon'})
- .home-panel-metadata.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'group_id_content' }, itemprop: 'identifier' }
+ .home-panel-metadata.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'group_id_content' }, itemprop: 'identifier' }
- if can?(current_user, :read_group, @group)
%span.gl-display-inline-block.gl-vertical-align-middle
= s_("GroupPage|Group ID: %{group_id}") % { group_id: @group.id }
diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml
index 632884051f0..94b0b018084 100644
--- a/app/views/groups/_new_group_fields.html.haml
+++ b/app/views/groups/_new_group_fields.html.haml
@@ -1,18 +1,18 @@
- parent = @group.parent
- submit_label = parent ? s_('GroupsNew|Create subgroup') : s_('GroupsNew|Create group')
-= form_errors(@group, pajamas_alert: true)
+= form_errors(@group)
= render 'shared/groups/group_name_and_path_fields', f: f, autofocus: true, new_subgroup: !!parent
-- unless parent
- .row
- .form-group.gl-form-group.col-sm-12
- %label.label-bold
- = _('Visibility level')
- %p
- = _('Who will be able to see this group?')
- = link_to _('View the documentation'), help_page_path("user/public_access"), target: '_blank', rel: 'noopener noreferrer'
- = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false
+.row
+ .form-group.gl-form-group.col-sm-12
+ %label.label-bold
+ = _('Visibility level')
+ %p
+ = _('Who will be able to see this group?')
+ = link_to _('View the documentation'), help_page_path("user/public_access"), target: '_blank', rel: 'noopener noreferrer'
+ = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false
+- unless parent
- if Gitlab.config.mattermost.enabled
.row
= render 'create_chat_team', f: f
diff --git a/app/views/groups/crm/organizations/index.html.haml b/app/views/groups/crm/organizations/index.html.haml
index ff1ba678de0..f7702889eef 100644
--- a/app/views/groups/crm/organizations/index.html.haml
+++ b/app/views/groups/crm/organizations/index.html.haml
@@ -5,4 +5,4 @@
= content_for :after_content do
#js-crm-form-portal
-#js-crm-organizations-app{ data: { base_path: group_crm_organizations_path(@group), can_admin_crm_organization: can?(current_user, :admin_crm_organization, @group).to_s, group_full_path: @group.full_path, group_id: @group.id, group_issues_path: issues_group_path(@group) } }
+#js-crm-organizations-app{ data: { base_path: group_crm_organizations_path(@group), can_admin_crm_organization: can?(current_user, :admin_crm_organization, @group).to_s, group_full_path: @group.full_path, group_id: @group.id, group_issues_path: issues_group_path(@group), text_query: params[:search] } }
diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml
index a8a52b2aba7..59ad29ccabd 100644
--- a/app/views/groups/harbor/repositories/index.html.haml
+++ b/app/views/groups/harbor/repositories/index.html.haml
@@ -4,8 +4,9 @@
#js-harbor-registry-list-group{ data: { endpoint: group_harbor_repositories_path(@group),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
- "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
- "registry_host_url_with_port" => 'demo.harbor.com',
+ "repository_url" => @group.harbor_integration.hostname,
+ "harbor_integration_project_name" => @group.harbor_integration.project_name,
+ full_path: @group.full_path,
connection_error: (!!@connection_error).to_s,
invalid_path_error: (!!@invalid_path_error).to_s,
is_group_page: true.to_s } }
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index 9f13ad301bb..3864b30eb1e 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
- = form_errors(@milestone, pajamas_alert: true)
+ = form_errors(@milestone)
.form-group.row
.col-form-label.col-sm-2
= f.label :title, _("Title")
diff --git a/app/views/groups/observability/index.html.haml b/app/views/groups/observability/index.html.haml
new file mode 100644
index 00000000000..582651c329b
--- /dev/null
+++ b/app/views/groups/observability/index.html.haml
@@ -0,0 +1,2 @@
+- page_title _("Observability")
+%iframe{ id: 'observability-ui-iframe', src: @observability_iframe_src, frameborder: 0, width: "100%", height: "100%" }
diff --git a/app/views/groups/runners/edit.html.haml b/app/views/groups/runners/edit.html.haml
index c5999317597..4bd550eaa47 100644
--- a/app/views/groups/runners/edit.html.haml
+++ b/app/views/groups/runners/edit.html.haml
@@ -1,14 +1,7 @@
+- runner_name = "##{@runner.id} (#{@runner.short_sha})"
- breadcrumb_title _('Edit')
-- page_title _('Edit'), "##{@runner.id} (#{@runner.short_sha})"
-
+- page_title _('Edit'), runner_name
- add_to_breadcrumbs _('Runners'), group_runners_path(@group)
-- add_to_breadcrumbs "#{@runner.short_sha}", group_runner_path(@group, @runner)
-
-
-%h1.page-title.gl-font-size-h-display
- = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
- = render 'shared/runners/runner_type_badge', runner: @runner
-
-= render 'shared/runners/runner_type_alert', runner: @runner
+- add_to_breadcrumbs runner_name, group_runner_path(@group, @runner)
-= render 'shared/runners/form', runner: @runner, runner_form_url: group_runner_path(@group, @runner)
+#js-group-runner-edit{ data: {runner_id: @runner.id, runner_path: group_runner_path(@group, @runner) } }
diff --git a/app/views/groups/runners/index.html.haml b/app/views/groups/runners/index.html.haml
index a67a4f28c93..1146063969b 100644
--- a/app/views/groups/runners/index.html.haml
+++ b/app/views/groups/runners/index.html.haml
@@ -1,3 +1,3 @@
- page_title s_('Runners|Runners')
-#js-group-runners{ data: group_runners_data_attributes(@group).merge( { group_runners_limited_count: @group_runners_limited_count } ) }
+#js-group-runners{ data: group_runners_data_attributes(@group).merge( { group_runners_limited_count: @group_runners_limited_count, registration_token: @group_runner_registration_token } ) }
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index 8fa8eeea3cd..21b1986bd34 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -4,7 +4,7 @@
.sub-section
%h4.warning-title= s_('GroupSettings|Change group URL')
= form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f|
- = form_errors(@group, pajamas_alert: true)
+ = form_errors(@group)
.form-group
%p
= s_("GroupSettings|Changing a group's URL can have unintended side effects.")
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 527791dfc04..be9d2c45885 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -1,6 +1,6 @@
= gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-settings-form' }, authenticity_token: true do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-general-settings' }
- = form_errors(@group, pajamas_alert: true)
+ = form_errors(@group)
%fieldset
.row
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index a60ab43f566..e35c0341ec0 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -1,6 +1,6 @@
= gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-permissions-form' }, authenticity_token: true do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-permissions-settings' }
- = form_errors(@group, pajamas_alert: true)
+ = form_errors(@group)
%fieldset
%h5= _('Permissions')
diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
index 3691c470ea7..a55ccd94974 100644
--- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for group, url: update_auto_devops_group_settings_ci_cd_path(group), method: :patch do |f|
- = form_errors(group, pajamas_alert: true)
+ = form_errors(group)
%fieldset
.form-group
.card.gl-mb-3
diff --git a/app/views/groups/settings/packages_and_registries/show.html.haml b/app/views/groups/settings/packages_and_registries/show.html.haml
index 888419e463a..2861e696e31 100644
--- a/app/views/groups/settings/packages_and_registries/show.html.haml
+++ b/app/views/groups/settings/packages_and_registries/show.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title _('Packages & Registries')
-- page_title _('Packages & Registries')
+- breadcrumb_title _('Package and registry settings')
+- page_title _('Package and registry settings')
- @content_class = 'limit-container-width' unless fluid_layout
%section#js-packages-and-registries-settings{ data: { group_path: @group.full_path,
diff --git a/app/views/groups/settings/repository/_default_branch.html.haml b/app/views/groups/settings/repository/_default_branch.html.haml
index cae33820a05..844a5f890a4 100644
--- a/app/views/groups/settings/repository/_default_branch.html.haml
+++ b/app/views/groups/settings/repository/_default_branch.html.haml
@@ -8,7 +8,7 @@
= s_('GroupSettings|Set the initial name and protections for the default branch of new repositories created in the group.')
.settings-content
= gitlab_ui_form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@group, pajamas_alert: true)
+ = form_errors(@group)
- fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value(object: @group)}</code>"
%fieldset
diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml
index d3b9117c05b..a15652b3179 100644
--- a/app/views/groups/settings/repository/show.html.haml
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -2,7 +2,11 @@
- page_title _('Repository')
- @content_class = "limit-container-width" unless fluid_layout
-- deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.')
+- if can?(current_user, :admin_group, @group)
+ - 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 "default_branch", group: @group
+ = render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description
+ = render "default_branch", group: @group
+
+- if can?(current_user, :change_push_rules, @group)
+ = render "push_rules"
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index d8da77dc5cc..f474f8fbd3b 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -33,33 +33,36 @@
= render_if_exists 'groups/group_activity_analytics', group: @group
-.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
- .top-area.group-nav-container.justify-content-between
- .scrolling-tabs-container.inner-page-scroll-tabs
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
- -# `item_active` is set to `false` as the active state is set by `app/assets/javascripts/pages/groups/shared/group_details.js`
- -# TODO: Replace this approach in https://gitlab.com/gitlab-org/gitlab/-/issues/23466
- = 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 group_path, item_active: false, tab_class: 'js-subgroups_and_projects-tab', data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab' } do
- = _("Subgroups and projects")
- = gl_tab_link_to group_shared_path, item_active: false, tab_class: 'js-shared-tab', data: { target: 'div#shared', action: 'shared', toggle: 'tab' } do
- = _("Shared projects")
- = gl_tab_link_to group_archived_path, item_active: false, tab_class: 'js-archived-tab', data: { target: 'div#archived', action: 'archived', toggle: 'tab' } do
- = _("Archived projects")
+- if Feature.enabled?(:group_overview_tabs_vue, @group)
+ #js-group-overview-tabs{ data: group_overview_tabs_app_data(@group) }
+- else
+ .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
+ .top-area.group-nav-container.justify-content-between
+ .scrolling-tabs-container.inner-page-scroll-tabs
+ .fade-left= sprite_icon('chevron-lg-left', size: 12)
+ .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ -# `item_active` is set to `false` as the active state is set by `app/assets/javascripts/pages/groups/shared/group_details.js`
+ -# TODO: Replace this approach in https://gitlab.com/gitlab-org/gitlab/-/issues/23466
+ = 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 group_path, item_active: false, tab_class: 'js-subgroups_and_projects-tab', data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab' } do
+ = _("Subgroups and projects")
+ = gl_tab_link_to group_shared_path, item_active: false, tab_class: 'js-shared-tab', data: { target: 'div#shared', action: 'shared', toggle: 'tab' } do
+ = _("Shared projects")
+ = gl_tab_link_to group_archived_path, item_active: false, tab_class: 'js-archived-tab', data: { target: 'div#archived', action: 'archived', toggle: 'tab' } do
+ = _("Archived projects")
- .nav-controls.d-block.d-md-flex
- .group-search
- = render "shared/groups/search_form"
+ .nav-controls.d-block.d-md-flex
+ .group-search
+ = render "shared/groups/search_form"
- = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash
+ = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash
- .tab-content
- #subgroups_and_projects.tab-pane
- = render "subgroups_and_projects", group: @group
+ .tab-content
+ #subgroups_and_projects.tab-pane
+ = render "subgroups_and_projects", group: @group
- #shared.tab-pane
- = render "shared_projects", group: @group
+ #shared.tab-pane
+ = render "shared_projects", group: @group
- #archived.tab-pane
- = render "archived_projects", group: @group
+ #archived.tab-pane
+ = render "archived_projects", group: @group
diff --git a/app/views/help/drawers.html.haml b/app/views/help/drawers.html.haml
new file mode 100644
index 00000000000..7c173eb7b07
--- /dev/null
+++ b/app/views/help/drawers.html.haml
@@ -0,0 +1,2 @@
+= cache(@clean_path, expires_in: 1.day) do
+ = markdown get_markdown_without_frontmatter(@path)
diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml
index d4ced15b869..f66aa0840aa 100644
--- a/app/views/jira_connect/subscriptions/index.html.haml
+++ b/app/views/jira_connect/subscriptions/index.html.haml
@@ -1,4 +1,4 @@
-.js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) }
+.js-jira-connect-app{ data: jira_connect_app_data(@subscriptions, @current_jira_installation) }
= webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
= webpack_bundle_tag 'jira_connect_app'
diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml
index f5c823465be..97e118aba93 100644
--- a/app/views/layouts/_google_tag_manager_head.html.haml
+++ b/app/views/layouts/_google_tag_manager_head.html.haml
@@ -6,19 +6,20 @@
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
- 'analytics_storage': 'denied',
- 'ad_storage': 'denied',
- 'functionality_storage': 'denied',
- 'region': ['EU', 'UK', 'PE', 'RU'],
- 'wait_for_update': 500
- });
- gtag('consent', 'default', {
'analytics_storage': 'granted',
'ad_storage': 'granted',
'functionality_storage': 'granted',
'wait_for_update': 500
});
+ gtag('consent', 'default', {
+ 'analytics_storage': 'denied',
+ 'ad_storage': 'denied',
+ 'functionality_storage': 'denied',
+ 'region': ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'IS', 'LI', 'NO', 'GB', 'PE', 'RU'],
+ 'wait_for_update': 500
+ });
+
- if Feature.enabled?(:gtm_nonce, type: :ops)
= javascript_tag nonce: content_security_policy_nonce do
:plain
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 59d4c81358d..014e26c7613 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -27,6 +27,7 @@
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
%main.content{ id: "content-body", **page_itemtype }
= render "layouts/flash", extra_flash_class: 'limit-container-width'
+ = yield :after_flash_content
= yield :before_content
= yield
= yield :after_content
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 87a8b6dd870..6650e07be2a 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -18,10 +18,10 @@
= current_appearance&.title.presence || _('GitLab')
- if current_appearance&.description?
= brand_text
+ = render_if_exists 'layouts/devise_help_text'
.mb-3
.gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar
= yield
- = render_if_exists 'layouts/devise_help_text'
= render 'devise/shared/footer', footer_message: footer_message
diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml
index 2a865aeda40..61a57240ed5 100644
--- a/app/views/layouts/fullscreen.html.haml
+++ b/app/views/layouts/fullscreen.html.haml
@@ -6,12 +6,16 @@
= header_message
= render partial: "layouts/header/default", locals: { project: @project, group: @group }
.mobile-overlay
- .alert-wrapper.hide-when-top-nav-responsive-open
- = render 'shared/outdated_browser'
- = render "layouts/broadcast"
- = yield :flash_message
- = render "layouts/flash"
- .content-wrapper.hide-when-top-nav-responsive-open{ id: "content-body", class: "d-flex flex-column align-items-stretch" }
- = yield
+ .hide-when-top-nav-responsive-open.gl--flex-full.gl-h-full{ class: nav ? ["layout-page", page_with_sidebar_class, "gl-mt-0!"]: '' }
+ - if defined?(nav) && nav
+ = render "layouts/nav/sidebar/#{nav}"
+ .gl--flex-full.gl-flex-direction-column.gl-w-full
+ .alert-wrapper
+ = render 'shared/outdated_browser'
+ = render "layouts/broadcast"
+ = yield :flash_message
+ = render "layouts/flash"
+ .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch" }
+ = yield
= render "layouts/nav/top_nav_responsive", class: "gl-flex-grow-1 gl-overflow-y-auto"
= footer_message
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 67809cbc608..97c2b8bb7e3 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -4,9 +4,10 @@
- nav "group"
- display_subscription_banner!
- @left_sidebar = true
+- base_layout = local_assigns[:base_layout]
- content_for :flash_message do
- = render "layouts/header/storage_enforcement_banner", context: @group
+ = dispensable_render_if_exists "groups/storage_enforcement_alert", context: @group
= dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @group
- content_for :page_specific_javascripts do
@@ -15,4 +16,4 @@
:plain
window.uploads_path = "#{group_uploads_path(@group)}";
-= render template: "layouts/application"
+= render template: base_layout || "layouts/application"
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 353f07c07c5..00e7a0567da 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -37,7 +37,7 @@
%li.d-md-none
= render 'shared/help_dropdown_forum_link'
%li.d-md-none
- = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback"
+ = link_to _("Submit feedback"), Gitlab::Utils.append_path(promo_url, "submit-feedback")
- if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
%li.d-md-none
= render 'shared/user_dropdown_contributing_link'
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 783733bb313..a00c5c186cc 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -5,15 +5,15 @@
%a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content
.container-fluid
.header-content.js-header-content
- .title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0
+ .title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3
.title
%span.gl-sr-only GitLab
= link_to root_path, title: _('Dashboard'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do
= brand_header_logo
+ .gl-display-flex.gl-align-items-center
- if Gitlab.com_and_canary?
- = link_to Gitlab::Saas.canary_toggle_com_url, class: 'canary-badge bg-transparent', data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer' do
- = gl_badge_tag({ variant: :success, size: :sm }) do
- = _('Next')
+ = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do
+ = _('Next')
- if current_user
.gl-display-none.gl-sm-display-block
@@ -28,11 +28,25 @@
.gl-display-none.gl-sm-display-block
= render "layouts/nav/top_nav"
- .navbar-collapse.gl-transition-medium.collapse
+ - if top_nav_show_search && Feature.enabled?(:new_navbar_layout)
+ .navbar-collapse.gl-transition-medium.collapse.gl-mr-auto.global-search-container.hide-when-top-nav-responsive-open
+ - search_menu_item = top_nav_search_menu_item_attrs
+ %ul.nav.navbar-nav.gl-w-full.gl-align-items-center
+ %li.nav-item.header-search-new.gl-display-none.gl-lg-display-block.gl-w-full
+ - unless current_controller?(:search)
+ - if Feature.enabled?(:new_header_search)
+ = render 'layouts/header_search'
+ - else
+ = render 'layouts/search'
+ %li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' }
+ = link_to search_menu_item.fetch(:href), title: search_menu_item.fetch(:title), aria: { label: search_menu_item.fetch(:title) }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = sprite_icon(search_menu_item.fetch(:icon))
+
+ .navbar-collapse.gl-transition-medium.collapse{ class: ('global-search-container' unless Feature.enabled?(:new_navbar_layout)) }
%ul.nav.navbar-nav.gl-w-full.gl-align-items-center.gl-justify-content-end
- if current_user
= render 'layouts/header/new_dropdown', class: 'gl-display-none gl-sm-display-block gl-white-space-nowrap gl-text-right'
- - if top_nav_show_search
+ - if top_nav_show_search && Feature.disabled?(:new_navbar_layout)
- search_menu_item = top_nav_search_menu_item_attrs
%li.nav-item.header-search-new.gl-display-none.gl-lg-display-block.gl-w-full
- unless current_controller?(:search)
@@ -116,7 +130,7 @@
= render "layouts/nav/top_nav"
- e.control {}
- if header_link?(:user_dropdown)
- %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_action: "click_dropdown", track_value: "", qa_selector: 'user_menu' }, class: ('mr-0' if has_impersonation_link) }
+ %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_action: "click_dropdown", track_value: "", qa_selector: 'user_menu', testid: 'user-menu' }, class: ('mr-0' if has_impersonation_link) }
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar", alt: current_user.name
= render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group
diff --git a/app/views/layouts/header/_storage_enforcement_banner.html.haml b/app/views/layouts/header/_storage_enforcement_banner.html.haml
deleted file mode 100644
index 1f7060f8235..00000000000
--- a/app/views/layouts/header/_storage_enforcement_banner.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-- return unless current_user
-- context = local_assigns.fetch(:context)
-- banner_info = storage_enforcement_banner_info(context)
-- return unless banner_info.present?
-
-= render Pajamas::AlertComponent.new(variant: :warning,
- alert_options: { class: 'js-storage-enforcement-banner',
- data: { feature_id: banner_info[:callouts_feature_name],
- dismiss_endpoint: banner_info[:callouts_path],
- group_id: banner_info[:namespace_id],
- defer_links: "true" }}) do |c|
- = c.body do
- %p= banner_info[:text_paragraph_1]
- %p= banner_info[:text_paragraph_2]
- %p= banner_info[:text_paragraph_3]
diff --git a/app/views/layouts/nav/_top_nav.html.haml b/app/views/layouts/nav/_top_nav.html.haml
index 42119ddb291..aa1c462d2bf 100644
--- a/app/views/layouts/nav/_top_nav.html.haml
+++ b/app/views/layouts/nav/_top_nav.html.haml
@@ -1,9 +1,10 @@
- view_model = top_nav_view_model(project: @project, group: @group)
-%ul.list-unstyled.navbar-sub-nav#js-top-nav{ data: { view_model: view_model.to_json } }
+%ul.list-unstyled.nav.navbar-sub-nav#js-top-nav{ data: { view_model: view_model.to_json } }
%li
%a.top-nav-toggle{ href: '#', type: 'button', data: { toggle: "dropdown" } }
- = sprite_icon('hamburger', css_class: "dropdown-icon")
- = view_model[:activeTitle]
+ = sprite_icon('hamburger')
+ - if view_model[:menuTitle]
+ .gl-ml-3= view_model[:menuTitle]
.hidden
- view_model[:shortcuts].each do |shortcut|
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index f3f79750643..56f333664df 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -175,7 +175,7 @@
%strong.fly-out-top-item-name
= _('Kubernetes')
- - if akismet_enabled?
+ - if anti_spam_service_enabled?
= nav_link(controller: :spam_logs) do
= link_to admin_spam_logs_path do
.nav-icon-container
diff --git a/app/views/layouts/profile.html.haml b/app/views/layouts/profile.html.haml
index 1ec839ef642..1b6e78b7b3d 100644
--- a/app/views/layouts/profile.html.haml
+++ b/app/views/layouts/profile.html.haml
@@ -7,7 +7,7 @@
- enable_search_settings locals: { container_class: 'gl-my-5' }
- content_for :flash_message do
- = render "layouts/header/storage_enforcement_banner", context: current_user.namespace
+ = dispensable_render_if_exists "profiles/storage_enforcement_alert", context: current_user.namespace
= dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: current_user.namespace
= render template: "layouts/application"
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 9503e874fd0..75d5e40011c 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -8,7 +8,7 @@
- @content_class = [@content_class, project_classes(@project)].compact.join(" ")
- content_for :flash_message do
- = render "layouts/header/storage_enforcement_banner", context: @project
+ = dispensable_render_if_exists "projects/storage_enforcement_alert", context: @project
= dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @project
- content_for :project_javascripts do
@@ -18,4 +18,6 @@
:plain
window.uploads_path = "#{project_uploads_path(project)}";
+= dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert"
+
= render template: "layouts/application"
diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml
index fc4a063f5a9..bb35bfffe46 100644
--- a/app/views/notify/_failed_builds.html.haml
+++ b/app/views/notify/_failed_builds.html.haml
@@ -18,6 +18,6 @@
%td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; color: #d22f57; font-weight: 500; font-size: 16px; vertical-align: middle; padding-right: 8px; line-height: 10px" }
%img{ alt: "✖", height: "10", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red.gif'), style: "display: block;", width: "10" }/
%td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; color: #8c8c8c; font-weight: 500; font-size: 14px; vertical-align: middle;" }
- = build.stage
+ = build.stage_name
%td{ align: "right", style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 16px 0; color: #8c8c8c; font-weight: 500; font-size: 14px;" }
= render "notify/links/#{build.to_partial_path}", pipeline: pipeline, build: build
diff --git a/app/views/notify/_successful_pipeline.html.haml b/app/views/notify/_successful_pipeline.html.haml
index e77db14a9c5..88e0bbf6125 100644
--- a/app/views/notify/_successful_pipeline.html.haml
+++ b/app/views/notify/_successful_pipeline.html.haml
@@ -16,7 +16,8 @@
%table.table-info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" }
%tbody
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" }
+ = _('Project')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;" }
- namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name
- namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner)
@@ -26,7 +27,8 @@
%a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" }
= @project.name
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = _('Branch')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
@@ -37,7 +39,8 @@
%a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" }
= @pipeline.source_ref
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = _('Commit')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:400;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
@@ -55,7 +58,8 @@
= @pipeline.git_commit_message.truncate(50)
- commit = @pipeline.commit
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit Author
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = s_('Notify|Commit Author')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
@@ -71,7 +75,8 @@
= commit.author_name
- if commit.different_committer?
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Committed by
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = s_('Notify|Committed by')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
diff --git a/app/views/notify/approved_merge_request_email.html.haml b/app/views/notify/approved_merge_request_email.html.haml
index 28da1182d49..0b20d4f3d3a 100644
--- a/app/views/notify/approved_merge_request_email.html.haml
+++ b/app/views/notify/approved_merge_request_email.html.haml
@@ -78,10 +78,11 @@
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;" }
%img{ alt: "✓", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-check-green-inverted.gif'), style: "display:block;", width: "13" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" }
- - if @merge_request.respond_to? :approvals_required
- %span Merge request was approved (#{@merge_request.approvals.count}/#{@merge_request.approvals_required})
- - else
- %span Merge request was approved
+ %span
+ - if @merge_request.respond_to? :approvals_required
+ = s_('Notify|Merge request was approved (%{approvals}/%{required_approvals})') % { approvals: @merge_request.approvals.count, required_approvals: @merge_request.approvals_required }
+ - else
+ = s_('Notify|Merge request was approved')
%tr.spacer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }
&nbsp;
@@ -92,12 +93,7 @@
%tr{ style: 'width:100%;' }
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;text-align:center;" }
%img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" }
- %span{ style: "font-weight: 600;color:#333333;" } Merge request
- %a{ href: merge_request_url(@merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none" }= @merge_request.to_reference
- %span was approved by
- %img.avatar{ height: "24", src: avatar_icon_for_user(@approved_by, 24, only_path: false), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" }/
- %a.muted{ href: user_url(@approved_by), style: "color:#333333;text-decoration:none;" }
- = @approved_by.name
+ = s_('Notify|%{mr_highlight}Merge request%{highlight_end} %{mr_link} %{reviewer_highlight}was approved by%{highlight_end} %{reviewer_avatar} %{reviewer_link}').html_safe % merge_request_hash_param(@merge_request, @approved_by)
%tr.spacer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }
&nbsp;
@@ -106,7 +102,7 @@
%table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" }
%tbody
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" }= _("Project")
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" }
- namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name
- namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner)
@@ -116,7 +112,7 @@
%a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" }
= @project.name
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }= _("Branch")
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
@@ -127,7 +123,7 @@
%span.muted{ style: "color:#333333;text-decoration:none;" }
= @merge_request.source_branch
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Author
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }= _("Author")
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb
index c75857e96d7..da91ac67ff7 100644
--- a/app/views/notify/autodevops_disabled_email.text.erb
+++ b/app/views/notify/autodevops_disabled_email.text.erb
@@ -12,6 +12,6 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
<% failed.each do |build| -%>
<%= render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build %>
- Stage: <%= build.stage %>
+ Stage: <%= build.stage_name %>
Name: <%= build.name %>
<% end -%>
diff --git a/app/views/notify/change_in_merge_request_draft_status_email.html.haml b/app/views/notify/change_in_merge_request_draft_status_email.html.haml
index 64ceb77e85c..21ea756cf06 100644
--- a/app/views/notify/change_in_merge_request_draft_status_email.html.haml
+++ b/app/views/notify/change_in_merge_request_draft_status_email.html.haml
@@ -1,2 +1,6 @@
-%p= html_escape(_('%{username} changed the draft status of merge request %{mr_link}')) % { username: link_to(@updated_by_user.name, user_url(@updated_by_user)),
- mr_link: merge_request_reference_link(@merge_request) }
+- if @merge_request.draft?
+ %p= html_escape(_('%{username} marked merge request %{mr_link} as draft')) % { username: link_to(@updated_by_user.name, user_url(@updated_by_user)),
+ mr_link: merge_request_reference_link(@merge_request) }
+- else
+ %p= html_escape(_('%{username} marked merge request %{mr_link} as ready')) % { username: link_to(@updated_by_user.name, user_url(@updated_by_user)),
+ mr_link: merge_request_reference_link(@merge_request) }
diff --git a/app/views/notify/change_in_merge_request_draft_status_email.text.erb b/app/views/notify/change_in_merge_request_draft_status_email.text.erb
index 4e2df2dff1d..8fe622f1e6b 100644
--- a/app/views/notify/change_in_merge_request_draft_status_email.text.erb
+++ b/app/views/notify/change_in_merge_request_draft_status_email.text.erb
@@ -1 +1,5 @@
-<%= "#{sanitize_name(@updated_by_user.name)} changed the draft status of merge request #{@merge_request.to_reference}" %>
+<% if @merge_request.draft? %>
+<%= _("#{sanitize_name(@updated_by_user.name)} marked merge request #{@merge_request.to_reference} as draft") %>
+<% else %>
+<%= _("#{sanitize_name(@updated_by_user.name)} marked merge request #{@merge_request.to_reference} as ready") %>
+<% end %>
diff --git a/app/views/notify/import_issues_csv_email.html.haml b/app/views/notify/import_issues_csv_email.html.haml
index f30d2b5f078..0008085025b 100644
--- a/app/views/notify/import_issues_csv_email.html.haml
+++ b/app/views/notify/import_issues_csv_email.html.haml
@@ -1,18 +1,18 @@
- text_style = 'font-size:16px; text-align:center; line-height:30px;'
%p{ style: text_style }
- Your CSV import for project
- %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none;" }
- = @project.full_name
- has been completed.
+ - project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none;")
+ = s_('Notify|Your CSV import for project %{project_link} has been completed.').html_safe % { project_link: project_link }
%p{ style: text_style }
- #{pluralize(@results[:success], 'issue')} imported.
+ - issues = n_('%d issue', '%d issues', @results[:success]) % @results[:success]
+ = s_('Notify|%{issues} imported.') % { issues: issues }
- if @results[:error_lines].present?
%p{ style: text_style }
- Errors found on line #{'number'.pluralize(@results[:error_lines].size)}: #{@results[:error_lines].join(', ')}. Please check if these lines have an issue title.
+ = s_('Notify|Errors found on %{singular_or_plural_line}: %{error_lines}. Please check if these lines have an issue title.') % { singular_or_plural_line: n_('line', 'lines', @results[:error_lines].size),
+ error_lines: @results[:error_lines].join(', ') }
- if @results[:parse_error]
%p{ style: text_style }
- Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.
+ = s_('Notify|Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.')
diff --git a/app/views/notify/new_gpg_key_email.html.haml b/app/views/notify/new_gpg_key_email.html.haml
index b857705e01f..fca0dbd168a 100644
--- a/app/views/notify/new_gpg_key_email.html.haml
+++ b/app/views/notify/new_gpg_key_email.html.haml
@@ -1,10 +1,9 @@
%p
- Hi #{sanitize_name(@user.name)}!
+ = s_("Notify|Hi %{user}!") % { user: sanitize_name(@user.name) }
%p
- A new GPG key was added to your account:
+ = s_("Notify|A new GPG key was added to your account:")
%p
- Fingerprint:
- %code= @gpg_key.fingerprint
+ = s_("Notify|Fingerprint: %{fingerprint}").html_safe % { fingerprint: content_tag(:code, @gpg_key.fingerprint) }
%p
- If this key was added in error, you can remove it under
- = link_to "GPG Keys", profile_gpg_keys_url
+ - removal_link = link_to _("GPG Keys"), profile_gpg_keys_url
+ = s_("Notify|If this key was added in error, you can remove it under %{removal_link}").html_safe % { removal_link: removal_link }
diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml
index 6b45ac265f7..3b2e36d118b 100644
--- a/app/views/notify/new_mention_in_issue_email.html.haml
+++ b/app/views/notify/new_mention_in_issue_email.html.haml
@@ -1,4 +1,4 @@
%p
- You have been mentioned in an issue.
+ = s_('Notify|You have been mentioned in an issue.')
= render template: 'notify/new_issue_email'
diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml
index a28d944529f..e4588716d5c 100644
--- a/app/views/notify/new_mention_in_merge_request_email.html.haml
+++ b/app/views/notify/new_mention_in_merge_request_email.html.haml
@@ -1,4 +1,4 @@
%p
- You have been mentioned in merge request #{merge_request_reference_link(@merge_request)}
+ = (s_("Notify|You have been mentioned in merge request %{mr_link}") % { mr_link: merge_request_reference_link(@merge_request) }).html_safe
= render template: 'notify/new_merge_request_email'
diff --git a/app/views/notify/new_ssh_key_email.html.haml b/app/views/notify/new_ssh_key_email.html.haml
index d031842be95..38c6dfae411 100644
--- a/app/views/notify/new_ssh_key_email.html.haml
+++ b/app/views/notify/new_ssh_key_email.html.haml
@@ -1,10 +1,4 @@
-%p
- Hi #{sanitize_name(@user.name)}!
-%p
- A new public key was added to your account:
-%p
- title:
- %code= @key.title
-%p
- If this key was added in error, you can remove it under
- = link_to "SSH Keys", profile_keys_url
+- name = sanitize_name(@user.name)
+- key_title = html_escape(@key.title)
+= (s_("Notify|%{paragraph_start}Hi %{name}!%{paragraph_end} %{paragraph_start}A new public key was added to your account:%{paragraph_end} %{paragraph_start}title: %{key_title}%{paragraph_end} %{paragraph_start}If this key was added in error, you can remove it under %{removal_link}%{paragraph_end}") % { paragraph_start: '<p>'.html_safe,
+ paragraph_end: '</p>'.html_safe, name: name, key_title: content_tag(:code, key_title), removal_link: link_to(_("SSH Keys"), profile_keys_url) }).html_safe
diff --git a/app/views/notify/new_user_email.html.haml b/app/views/notify/new_user_email.html.haml
index ec135ae994f..11660126dc2 100644
--- a/app/views/notify/new_user_email.html.haml
+++ b/app/views/notify/new_user_email.html.haml
@@ -1,17 +1,19 @@
%p
- Hi #{sanitize_name(@user['name'])}!
+ = s_('Notify|Hi %{username}!') % {username: sanitize_name(@user['name'])}
%p
- if Gitlab::CurrentSettings.allow_signup?
- Your account has been created successfully.
+ = s_('Notify|Your account has been created successfully.')
- else
- The Administrator created an account for you. Now you are a member of the company GitLab application.
+ = s_('Notify|The Administrator created an account for you. Now you are a member of the company GitLab application.')
%p
- login..........................................
+ = s_('Notify|login..........................................')
%code= @user['email']
- if @user.created_by_id
%p
- = link_to "Click here to set your password", edit_password_url(@user, reset_password_token: @token)
+ = link_to s_('Notify|Click here to set your password'), edit_password_url(@user, reset_password_token: @token)
%p
- This link is valid for #{password_reset_token_valid_time}.
- After it expires, you can #{link_to("request a new one", new_user_password_url(user_email: @user.email))}.
+ = s_('Notify|This link is valid for %{password_reset_token_valid_time}.') % {password_reset_token_valid_time: password_reset_token_valid_time}
+ - a_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % {url: new_user_password_url(user_email: @user.email)}
+ - a_end = '</a>'.html_safe
+ = html_escape(s_('Notify|After it expires, you can %{a_start} request a new one %{a_end}.')) % {a_start: a_start, a_end: a_end}
diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb
index 6ab74bcfb1a..c82b7a8dd2a 100644
--- a/app/views/notify/pipeline_failed_email.text.erb
+++ b/app/views/notify/pipeline_failed_email.text.erb
@@ -32,6 +32,6 @@ had <%= failed.size %> failed <%= 'job'.pluralize(failed.size) %>.
<% failed.each do |build| -%>
<%= render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build %>
-Stage: <%= build.stage %>
+Stage: <%= build.stage_name %>
Name: <%= build.name %>
<% end -%>
diff --git a/app/views/notify/pipeline_fixed_email.html.haml b/app/views/notify/pipeline_fixed_email.html.haml
index f2dbb3b20b7..33b83b104b1 100644
--- a/app/views/notify/pipeline_fixed_email.html.haml
+++ b/app/views/notify/pipeline_fixed_email.html.haml
@@ -1 +1 @@
-= render 'notify/successful_pipeline', title: "Pipeline has been fixed and ##{@pipeline.id} has passed!"
+= render 'notify/successful_pipeline', title: s_('Notify|Pipeline has been fixed and #%{pipeline_id} has passed!') % {pipeline_id: @pipeline.id}
diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml
index 5197a1bdd08..16612cd43c5 100644
--- a/app/views/notify/push_to_merge_request_email.html.haml
+++ b/app/views/notify/push_to_merge_request_email.html.haml
@@ -1,7 +1,7 @@
%h3
- = sanitize_name(@updated_by_user.name)
- pushed new commits to merge request
- = merge_request_reference_link(@merge_request)
+ - updated_by_user_name = sanitize_name(@updated_by_user.name)
+ - mr_link = sanitize(merge_request_reference_link(@merge_request))
+ = s_('Notify|%{updated_by_user_name} pushed new commits to merge request %{mr_link}').html_safe % {updated_by_user_name: updated_by_user_name, mr_link: mr_link}
- if @total_existing_commits_count > 0
%ul
@@ -13,8 +13,8 @@
= link_to(project_compare_url(@merge_request.target_project, from: @existing_commits.first[:short_id], to: @existing_commits.last[:short_id])) do
#{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]}
= precede '&nbsp;- ' do
- - commits_text = "#{@total_existing_commits_count} commit".pluralize(@total_existing_commits_count)
- #{commits_text} from branch `#{@merge_request.target_branch}`
+ - commits_text = n_("%d commit", "%d commits", @total_existing_commits_count) % @total_existing_commits_count
+ = s_('Notify|%{commits_text} from branch `%{target_branch}`') % {commits_text: commits_text, target_branch: @merge_request.target_branch}
- if @total_new_commits_count > 0
%ul
@@ -24,4 +24,5 @@
= precede ' - ' do
#{commit[:title]}
- if @total_stripped_new_commits_count > 0
- %li And #{@total_stripped_new_commits_count} more
+ %li
+ = s_('Notify|And %{total_stripped_new_commits_count} more') % {total_stripped_new_commits_count: @total_stripped_new_commits_count}
diff --git a/app/views/notify/remote_mirror_update_failed_email.html.haml b/app/views/notify/remote_mirror_update_failed_email.html.haml
index 4fb0a4c5a8a..db95398d2d6 100644
--- a/app/views/notify/remote_mirror_update_failed_email.html.haml
+++ b/app/views/notify/remote_mirror_update_failed_email.html.haml
@@ -6,7 +6,7 @@
%td{ style: "vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;line-height:1;" }
%img{ alt: "✖", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red-inverted.gif'), style: "display:block;", width: "13" }/
%td{ style: "vertical-align:middle;color:#ffffff;text-align:center;" }
- A remote mirror update has failed.
+ = s_('Notify|A remote mirror update has failed.')
%tr.spacer{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
%td{ style: "height:18px;font-size:18px;line-height:18px;" }
&nbsp;
@@ -15,7 +15,8 @@
%table.table-info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" }
%tbody{ style: "font-size:15px;line-height:1.4;color:#8c8c8c;" }
%tr
- %td{ style: "font-weight:300;padding:14px 0;margin:0;" } Project
+ %td{ style: "font-weight:300;padding:14px 0;margin:0;" }
+ = _('Project')
%td{ style: "font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;" }
- namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner)
%a.muted{ href: namespace_url, style: "color:#333333;text-decoration:none;" }
@@ -24,17 +25,20 @@
%a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" }
= @project.name
%tr
- %td{ style: "font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Remote mirror
+ %td{ style: "font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = s_('Notify|Remote mirror')
%td{ style: "font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
= @remote_mirror.safe_url
%tr
- %td{ style: "font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Last update at
- %td{ style: "font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
- = @remote_mirror.last_update_at
+ - update_at_start = '<td style="font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;">'.html_safe
+ - update_at_mid = '</td><td style="font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;">'.html_safe
+ - update_at_end = '</td>'.html_safe
+ = html_escape(s_('Notify|%{update_at_start} Last update at %{update_at_mid} %{last_update_at} %{update_at_end}')) % {update_at_start: update_at_start, update_at_mid: update_at_mid, last_update_at: @remote_mirror.last_update_at, update_at_end: update_at_end}
+
%tr.table-warning{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
%td{ style: "border: 1px solid #ededed; border-bottom: 0; border-radius: 4px 4px 0 0; overflow: hidden; background-color: #fdf4f6; color: #d22852; font-size: 14px; line-height: 1.4; text-align: center; padding: 8px 16px;" }
- Logs may contain sensitive data. Please consider before forwarding this email.
+ = s_('Notify|Logs may contain sensitive data. Please consider before forwarding this email.')
%tr.section{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
%td{ style: "padding: 0 16px; border: 1px solid #ededed; border-radius: 4px; overflow: hidden; border-top: 0; border-radius: 0 0 4px 4px;" }
%table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width: 100%; border-collapse: collapse;" }
diff --git a/app/views/notify/removed_milestone_issue_email.html.haml b/app/views/notify/removed_milestone_issue_email.html.haml
index 7e9205b6491..f411ea23832 100644
--- a/app/views/notify/removed_milestone_issue_email.html.haml
+++ b/app/views/notify/removed_milestone_issue_email.html.haml
@@ -1,2 +1,2 @@
%p
- Milestone removed
+ = s_('Notify|Milestone removed')
diff --git a/app/views/notify/removed_milestone_merge_request_email.html.haml b/app/views/notify/removed_milestone_merge_request_email.html.haml
index 7e9205b6491..f411ea23832 100644
--- a/app/views/notify/removed_milestone_merge_request_email.html.haml
+++ b/app/views/notify/removed_milestone_merge_request_email.html.haml
@@ -1,2 +1,2 @@
%p
- Milestone removed
+ = s_('Notify|Milestone removed')
diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml
index 93806e6de8e..ee219914513 100644
--- a/app/views/notify/repository_push_email.html.haml
+++ b/app/views/notify/repository_push_email.html.haml
@@ -2,29 +2,30 @@
= stylesheet_link_tag 'mailers/highlighted_diff_email'
%h3
- #{@message.author_name} #{@message.action_name} #{@message.ref_type} #{@message.ref_name}
- at #{link_to(@message.project_name_with_namespace, project_url(@message.project))}
+ = s_('Notify|%{author_name} %{action_name} %{ref_type} %{ref_name} at %{project_link}').html_safe % {author_name: @message.author_name, action_name: @message.action_name, ref_type: @message.ref_type, ref_name: @message.ref_name, project_link: link_to(@message.project_name_with_namespace, strip_tags(project_url(@message.project)))}
- if @message.compare
- if @message.reverse_compare?
%p
- %strong WARNING:
- The push did not contain any new commits, but force pushed to delete the commits and changes below.
+ %strong
+ = _('WARNING:')
+ = s_('Notify|The push did not contain any new commits, but force pushed to delete the commits and changes below.')
%h4
- = @message.reverse_compare? ? "Deleted commits:" : "Commits:"
+ = @message.reverse_compare? ? _("Deleted commits:") : _("Commits:")
%ul
- @message.commits.each do |commit|
%li
%strong= link_to(commit.short_id, project_commit_url(@message.project, commit))
%div
- %span by #{commit.author_name}
- %i at #{commit.committed_date.to_s(:iso8601)}
+ = html_escape(s_('Notify|%{committed_by_start} by %{author_name} %{committed_by_end} %{committed_at_start} at %{committed_date} %{committed_at_end}')) % {committed_by_start: '<span>'.html_safe, author_name: commit.author_name, committed_by_end: '</span>'.html_safe, committed_at_start: '<i>'.html_safe, committed_date: commit.committed_date.to_s(:iso8601), committed_at_end: '</i>'.html_safe}
%pre.commit-message
= commit.safe_message
- %h4 #{pluralize @message.diffs_count, "changed file"}:
+ %h4
+ - changed_files = n_('%d changed file', '%d changed files', @message.diffs_count) % @message.diffs_count
+ = s_('Notify|%{changed_files}:') % {changed_files: changed_files}
%ul
- @message.diffs.each do |diff_file|
@@ -47,9 +48,11 @@
- unless @message.disable_diffs?
- if @message.compare_timeout
- %h5 The diff was not included because it is too large.
+ %h5
+ = s_('Notify|The diff was not included because it is too large.')
- else
- %h4 Changes:
+ %h4
+ = _('Changes:')
- @message.diffs.each do |diff_file|
- file_hash = hexdigest(diff_file.file_path)
%li{ id: file_hash }
@@ -57,7 +60,7 @@
- if diff_file.deleted_file?
%strong<
= diff_file.old_path
- deleted
+ = s_('deleted')
- elsif diff_file.renamed_file?
%strong<
= diff_file.old_path
@@ -68,7 +71,7 @@
%strong<
= diff_file.new_path
- if diff_file.too_large?
- The diff for this file was not included because it is too large.
+ = s_('Notify|The diff for this file was not included because it is too large.')
- else
%hr
- blob = diff_file.blob
@@ -76,5 +79,5 @@
%table.code.white
= render partial: "projects/diffs/email_line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file }
- else
- No preview for this file type
+ = s_('Notify|No preview for this file type')
%br
diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml
index 209415e0aee..78dc21caf18 100644
--- a/app/views/notify/resolved_all_discussions_email.html.haml
+++ b/app/views/notify/resolved_all_discussions_email.html.haml
@@ -1,3 +1,2 @@
%p
- All discussions on merge request #{merge_request_reference_link(@merge_request)}
- were resolved by #{sanitize_name(@resolved_by.name)}
+ = s_('Notify|All discussions on merge request %{mr_link} were resolved by %{name}').html_safe % { mr_link: merge_request_reference_link(@merge_request), name: sanitize_name(@resolved_by.name) }
diff --git a/app/views/notify/send_admin_notification.html.haml b/app/views/notify/send_admin_notification.html.haml
index f7f1528f332..20c44df360c 100644
--- a/app/views/notify/send_admin_notification.html.haml
+++ b/app/views/notify/send_admin_notification.html.haml
@@ -3,5 +3,5 @@
\----
%p
- Don't want to receive updates from GitLab administrators?
- = link_to 'Unsubscribe', @unsubscribe_url
+ = s_("Notify|Don't want to receive updates from GitLab administrators?")
+ = link_to _('Unsubscribe'), @unsubscribe_url
diff --git a/app/views/notify/unapproved_merge_request_email.html.haml b/app/views/notify/unapproved_merge_request_email.html.haml
index 0b8fbe14228..94e2d0377aa 100644
--- a/app/views/notify/unapproved_merge_request_email.html.haml
+++ b/app/views/notify/unapproved_merge_request_email.html.haml
@@ -79,9 +79,11 @@
%img{ alt: "✗", height: "13", src: image_url('mailers/approval/icon-x-orange-inverted.gif'), style: "display:block;", width: "13" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" }
- if @merge_request.respond_to? :approvals_required
- %span Merge request was unapproved (#{@merge_request.approvals.count}/#{@merge_request.approvals_required})
+ %span
+ = s_('Notify|Merge request was unapproved (%{approvals_count}/%{approvals_required})') % {approvals_count: @merge_request.approvals.count, approvals_required: @merge_request.approvals_required}
- else
- %span Merge request was unapproved
+ %span
+ = s_('Notify|Merge request was unapproved')
%tr.spacer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }
&nbsp;
@@ -92,12 +94,7 @@
%tr{ style: 'width:100%;' }
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;text-align:center;" }
%img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" }
- %span{ style: "font-weight: 600;color:#333333;" } Merge request
- %a{ href: merge_request_url(@merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none" }= @merge_request.to_reference
- %span was unapproved by
- %img.avatar{ height: "24", src: avatar_icon_for_user(@unapproved_by, 24), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" }/
- %a.muted{ href: user_url(@unapproved_by), style: "color:#333333;text-decoration:none;" }
- = @unapproved_by.name
+ = s_('Notify|%{mr_highlight}Merge request%{highlight_end} %{mr_link} %{reviewer_highlight}was unapproved by%{highlight_end} %{reviewer_avatar} %{reviewer_link}').html_safe % merge_request_hash_param(@merge_request, @unapproved_by)
%tr.spacer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }
&nbsp;
@@ -106,7 +103,8 @@
%table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" }
%tbody
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" }
+ = _('Project')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" }
- namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name
- namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner)
@@ -116,7 +114,8 @@
%a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" }
= @project.name
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = _('Branch')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
@@ -127,7 +126,8 @@
%span.muted{ style: "color:#333333;text-decoration:none;" }
= @merge_request.source_branch
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Author
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = _('Author')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml
index 0ca9acba2de..0fde7fd4f19 100644
--- a/app/views/profiles/_email_settings.html.haml
+++ b/app/views/profiles/_email_settings.html.haml
@@ -25,8 +25,8 @@
= s_("Profiles|This email will be displayed on your public profile.")
.form-group.gl-form-group
- - commit_email_link_url = help_page_path('user/profile/index', anchor: 'change-the-email-displayed-on-your-commits', target: '_blank')
- - commit_email_link_start = '<a href="%{url}">'.html_safe % { url: commit_email_link_url }
+ - commit_email_link_url = help_page_path('user/profile/index', anchor: 'change-the-email-displayed-on-your-commits')
+ - commit_email_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: commit_email_link_url }
- commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more.%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe }
= form.label :commit_email, s_('Profiles|Commit email')
.gl-md-form-input-lg
diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml
index f444f236cfc..be835233528 100644
--- a/app/views/profiles/active_sessions/index.html.haml
+++ b/app/views/profiles/active_sessions/index.html.haml
@@ -10,6 +10,7 @@
.col-lg-8
.gl-mb-3
- .card.border-0
- %ul.list-group.list-group-flush
- = render partial: 'profiles/active_sessions/active_session', collection: @sessions
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-0' }, body_options: { class: 'gl-p-0' }) do |c|
+ - c.body do
+ %ul.list-group.list-group-flush
+ = render partial: 'profiles/active_sessions/active_session', collection: @sessions
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index ef9e7512b57..1b8f0328a04 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -38,29 +38,29 @@
= render partial: 'shared/email_with_badge', locals: { email: @primary_email, verified: current_user.confirmed? }
%ul
%li= s_('Profiles|Primary email')
- - if @primary_email === current_user.commit_email_or_default
+ - if @primary_email == current_user.commit_email_or_default
%li= s_('Profiles|Commit email')
- - if @primary_email === current_user.public_email
+ - if @primary_email == current_user.public_email
%li= s_('Profiles|Public email')
- - if @primary_email === current_user.notification_email_or_default
+ - if @primary_email == current_user.notification_email_or_default
%li= s_('Profiles|Default notification email')
- @emails.reject(&:user_primary_email?).each do |email|
%li{ data: { qa_selector: 'email_row_content' } }
- .gl-display-flex.gl-justify-content-space-between{ style: 'flex-flow: wrap-reverse; row-gap: 0.5rem' }
+ .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-gap-3
%div
= render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
- .gl-ml-n3
+ %ul
+ - if email.email == current_user.commit_email_or_default
+ %li= s_('Profiles|Commit email')
+ - if email.email == current_user.public_email
+ %li= s_('Profiles|Public email')
+ - if email.email == current_user.notification_email_or_default
+ %li= s_('Profiles|Notification email')
+ .gl-display-flex.gl-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-wrap-reverse.gl-gap-3
- unless email.confirmed?
- confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}"
- = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default gl-ml-3'
+ = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default'
- = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger gl-ml-3' do
+ = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger' do
%span.sr-only= _('Remove')
= sprite_icon('remove')
- %ul
- - if email.email === current_user.commit_email_or_default
- %li= s_('Profiles|Commit email')
- - if email.email === current_user.public_email
- %li= s_('Profiles|Public email')
- - if email.email === current_user.notification_email_or_default
- %li= s_('Profiles|Notification email')
diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml
index b3784faed28..9804a3b7735 100644
--- a/app/views/profiles/gpg_keys/_form.html.haml
+++ b/app/views/profiles/gpg_keys/_form.html.haml
@@ -1,6 +1,6 @@
%div
= form_for [:profile, @gpg_key], html: { class: 'js-requires-input' } do |f|
- = form_errors(@gpg_key, pajamas_alert: true)
+ = form_errors(@gpg_key)
.form-group
= f.label :key, s_('Profiles|Key'), class: 'label-bold'
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index a749fbd1eec..6f7eb21b7e0 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -1,7 +1,7 @@
- max_date = ::Gitlab::CurrentSettings.max_ssh_key_lifetime_from_now.to_date if ssh_key_expiration_policy_enabled?
%div
= form_for [:profile, @key], html: { class: 'js-requires-input' } do |f|
- = form_errors(@key, pajamas_alert: true)
+ = form_errors(@key)
.form-group
= f.label :key, s_('Profiles|Key'), class: 'label-bold'
@@ -13,10 +13,12 @@
= f.text_field :title, class: "form-control gl-form-input input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|Example: MacBook key')
%p.form-text.text-muted= s_('Profiles|Key titles are publicly visible.')
+ .form-row
.col.form-group
- = f.label :expires_at, s_('Profiles|Expiration date'), class: 'label-bold'
- = f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, max: max_date, data: { qa_selector: 'key_expiry_date_field' }
- %p.form-text.text-muted{ data: { qa_selector: 'key_expiry_date_field_description' } }= ssh_key_expires_field_description
+ .js-access-tokens-expires-at{ data: {min_date: Date.tomorrow, max_date: max_date, default_date_offset: 365, description: ssh_key_expires_field_description } }
+ = f.label :expires_at, s_('Profiles|Expiration date'), class: 'label-bold'
+ = f.text_field :expires_at, class: "gl-datepicker-input form-control gl-form-input", placeholder: 'YYYY-MM-DD', min: Date.tomorrow, max: max_date, data: { js_name: 'expiresAt' }
+ %p.form-text.text-muted= ssh_key_expires_field_description
.js-add-ssh-key-validation-warning.hide
.bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' }
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index 178ed01c766..de4a19bdad7 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -16,7 +16,7 @@
%span.gl-text-truncate.gl-sm-ml-3
= key.fingerprint
- .gl-mt-3= s_('Profiles|Created%{time_ago}'.html_safe) % { time_ago: time_ago_with_tooltip(key.created_at, html_class: 'gl-ml-2')}
+ .gl-mt-3= html_escape(s_('Profiles|Created%{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at, html_class: 'gl-ml-2').html_safe}
.key-list-item-dates
%span.last-used-at.gl-mr-3
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index 8f7ccadd108..8016d989ff1 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -19,7 +19,7 @@
%strong= @key.last_used_at.try(:to_s, :medium) || _('Never')
.col-md-8
- = form_errors(@key, type: 'key', pajamas_alert: true) unless @key.valid?
+ = form_errors(@key, type: 'key') unless @key.valid?
%pre.well-pre
= @key.key
.card
diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml
index b4db99a8bd4..c4de33dcd9e 100644
--- a/app/views/profiles/notifications/_email_settings.html.haml
+++ b/app/views/profiles/notifications/_email_settings.html.haml
@@ -1,6 +1,6 @@
- form = local_assigns.fetch(:form)
.form-group
- = form.label :notification_email, class: "label-bold"
+ = form.label :notification_email, _('Notification Email'), class: "label-bold"
= form.select :notification_email, @user.public_verified_emails, { include_blank: _('Use primary email (%{email})') % { email: @user.email }, selected: @user.notification_email }, class: "select2", disabled: local_assigns.fetch(:email_change_disabled, nil)
.help-block
= local_assigns.fetch(:help_text, nil)
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 26c9b2f0ee1..0f4b130a774 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -25,7 +25,7 @@
= gitlab_ui_form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f|
= render_if_exists 'profiles/notifications/email_settings', form: f
- = label_tag :global_notification_level, "Global notification level", class: "label-bold"
+ = label_tag :global_notification_level, _('Global notification level'), class: "label-bold"
%br
.clearfix
.form-group.float-left.global-notification-setting
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index 46ae602359f..257255eb4d7 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -15,7 +15,7 @@
- else
= _('Change your password or recover your current one')
= form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f|
- = form_errors(@user, pajamas_alert: true)
+ = form_errors(@user)
- unless @user.password_automatically_set?
.form-group
diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml
index 5bcc92dcdfd..6f260eb4cc0 100644
--- a/app/views/profiles/passwords/new.html.haml
+++ b/app/views/profiles/passwords/new.html.haml
@@ -9,7 +9,7 @@
%br
= _('After a successful password update you will be redirected to login screen.')
- = form_errors(@user, pajamas_alert: true)
+ = form_errors(@user)
- unless @user.password_automatically_set?
.form-group.row
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index f8737a4e54a..e16108c5c22 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,4 +1,5 @@
- page_title _('Preferences')
+- add_page_specific_style 'page_bundles/profiles/preferences'
- @content_class = "limit-container-width" unless fluid_layout
- user_theme_id = Gitlab::Themes.for_user(@user).id
- user_color_schema_id = Gitlab::ColorSchemes.for_user(@user).id
@@ -77,10 +78,10 @@
= s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' }
.form-group
= f.label :dashboard, class: 'label-bold' do
- = s_('Preferences|Homepage content')
+ = s_('Preferences|Dashboard')
= f.select :dashboard, dashboard_choices, {}, class: 'select2'
.form-text.text-muted
- = s_('Preferences|Choose what content you want to see on your homepage.')
+ = s_('Preferences|Choose what content you want to see by default on your dashboard.')
= render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index a64968cdcbb..f38d6021b18 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -2,8 +2,6 @@
- page_title s_("Profiles|Edit Profile")
- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
-- availability = availability_values
-- custom_emoji = @user.status&.customized?
= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
.row.js-search-settings-section
@@ -43,39 +41,12 @@
%h4.gl-mt-0= s_("Profiles|Current status")
%p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
.col-lg-8
- = f.fields_for :status, @user.status do |status_form|
- - emoji_button = render Pajamas::ButtonComponent.new(button_options: { title: s_("Profiles|Add status emoji"),
- class: 'js-toggle-emoji-menu emoji-menu-toggle-button has-tooltip' } ) do
- - if custom_emoji
- = emoji_icon(@user.status.emoji, class: 'gl-mr-0!')
- %span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if custom_emoji) }
- = sprite_icon('slight-smile', css_class: 'award-control-icon-neutral')
- = sprite_icon('smiley', css_class: 'award-control-icon-positive')
- = sprite_icon('smile', css_class: 'award-control-icon-super-positive')
- - reset_message_button = render Pajamas::ButtonComponent.new(icon: 'close',
- button_options: { id: 'js-clear-user-status-button',
- class: 'has-tooltip',
- title: s_("Profiles|Clear status") } )
-
- = status_form.hidden_field :emoji, id: 'js-status-emoji-field'
- .form-group.gl-form-group
- = status_form.label :message, s_("Profiles|Your status")
- .input-group{ role: 'group' }
- .input-group-prepend
- = emoji_button
- = status_form.text_field :message,
- id: 'js-status-message-field',
- class: 'form-control gl-form-input input-lg',
- placeholder: s_("Profiles|What's your status?")
- .input-group-append
- = reset_message_button
- .form-group.gl-form-group
- = status_form.gitlab_ui_checkbox_component :availability,
- s_("Profiles|Busy"),
- help_text: s_('Profiles|An indicator appears next to your name and avatar.'),
- checkbox_options: { data: { testid: "user-availability-checkbox" } },
- checked_value: availability["busy"],
- unchecked_value: availability["not_set"]
+ #js-user-profile-set-status-form
+ = f.fields_for :status, @user.status do |status_form|
+ = status_form.hidden_field :emoji, data: { js_name: 'emoji' }
+ = status_form.hidden_field :message, data: { js_name: 'message' }
+ = status_form.hidden_field :availability, data: { js_name: 'availability' }
+ = status_form.hidden_field :clear_status_after, data: { js_name: 'clearStatusAfter' }
.col-lg-12
%hr
.row.user-time-preferences.js-search-settings-section
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index c1eaa84e99d..855c73fd323 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -81,7 +81,7 @@
.col-lg-8
- registration = webauthn_enabled ? @webauthn_registration : @u2f_registration
- if registration.errors.present?
- = form_errors(registration, pajamas_alert: true)
+ = form_errors(registration)
- if webauthn_enabled
= render "authentication/register", target_path: create_webauthn_profile_two_factor_auth_path
- else
diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml
index 402affc7b0e..118f6fb1296 100644
--- a/app/views/projects/_activity.html.haml
+++ b/app/views/projects/_activity.html.haml
@@ -4,7 +4,7 @@
= render 'shared/event_filter'
.controls.gl-display-flex
= link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' do
- = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon')
+ = sprite_icon('rss', css_class: 'gl-icon')
- if is_project_overview && can?(current_user, :download_code, @project)
.project-clone-holder.d-none.d-md-inline-flex.gl-ml-2
= render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right'
diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index 952c6daf415..9962e03995b 100644
--- a/app/views/projects/_commit_button.html.haml
+++ b/app/views/projects/_commit_button.html.haml
@@ -1,5 +1,8 @@
.form-actions.gl-display-flex
- = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button', data: { qa_selector: 'commit_button' } }) do
+ - submit_button_options = { type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button', data: { qa_selector: 'commit_button' } } }
+ = render Pajamas::ButtonComponent.new(**submit_button_options) do
+ = _('Commit changes')
+ = render Pajamas::ButtonComponent.new(loading: true, disabled: true, **submit_button_options.merge({ button_options: { class: 'js-commit-button-loading gl-display-none' } })) do
= _('Commit changes')
= render Pajamas::ButtonComponent.new(href: cancel_path, button_options: { class: 'gl-ml-3', id: 'cancel-changes', aria: { label: _('Discard changes') }, data: { confirm: leave_edit_message, confirm_btn_variant: "danger" } }) do
diff --git a/app/views/projects/_errors.html.haml b/app/views/projects/_errors.html.haml
index 5982aaf3622..2dba22d3be6 100644
--- a/app/views/projects/_errors.html.haml
+++ b/app/views/projects/_errors.html.haml
@@ -1 +1 @@
-= form_errors(@project, pajamas_alert: true)
+= form_errors(@project)
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index c220aa66c81..7ff58d12b9c 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -10,12 +10,12 @@
= project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'image')
.d-flex.flex-column.flex-wrap.align-items-baseline
.d-inline-flex.align-items-baseline
- %h1.home-panel-title.gl-mt-3.gl-mb-2.gl-font-size-h1.gl-line-height-24.gl-font-weight-bold{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' }
+ %h1.home-panel-title.gl-mt-3.gl-mb-2.gl-font-size-h1{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' }
= @project.name
- %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
+ %span.visibility-icon.gl-text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, options: { class: 'icon' })
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project
- .home-panel-metadata.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'project_id_content' }, itemprop: 'identifier' }
+ .home-panel-metadata.gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'project_id_content' }, itemprop: 'identifier' }
- if can?(current_user, :read_project, @project)
%span.gl-display-inline-block.gl-vertical-align-middle
= s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
index cee3d9071b6..349cd88437f 100644
--- a/app/views/projects/_service_desk_settings.html.haml
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -17,6 +17,7 @@
selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}",
outgoing_name: "#{@project.service_desk_setting&.outgoing_name}",
project_key: "#{@project.service_desk_setting&.project_key}",
- templates: available_service_desk_templates_for(@project) } }
+ templates: available_service_desk_templates_for(@project),
+ public_project: "#{@project.public?}" } }
- elsif show_callout?('promote_service_desk_dismissed')
= render 'shared/promotions/promote_servicedesk'
diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml
index 4a21cb32c20..1409b28e735 100644
--- a/app/views/projects/_stat_anchor_list.html.haml
+++ b/app/views/projects/_stat_anchor_list.html.haml
@@ -2,7 +2,7 @@
- project_buttons = local_assigns.fetch(:project_buttons, false)
- return unless anchors.any?
-%ul.nav.gl-gap-3
+%ul.nav{ class: (project_buttons ? 'gl-gap-3' : 'gl-gap-5') }
- anchors.each do |anchor|
%li.nav-item
= link_to_if(anchor.link, anchor.label, anchor.link, stat_anchor_attrs(anchor)) do
diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml
index 6a4760c3954..674b21b66b9 100644
--- a/app/views/projects/activity.html.haml
+++ b/app/views/projects/activity.html.haml
@@ -1,4 +1,6 @@
- page_title _("Activity")
+= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
+
= render 'projects/last_push'
= render 'projects/activity'
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index b44c773adff..f2c4fe017f2 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -1,9 +1,9 @@
- page_title _("Blame"), @blob.path, @ref
-#blob-content-holder.tree-holder
+#blob-content-holder.tree-holder{ data: { testid: 'blob-content-holder' } }
= render "projects/blob/breadcrumb", blob: @blob, blame: true
- .file-holder
+ .file-holder.gl-overflow-hidden
= render "projects/blob/header", blob: @blob, blame: true
.file-blame-legend
@@ -20,7 +20,7 @@
%span.legend-box.legend-box-9
%span.right-label Older
- .table-responsive.file-content.blame.code{ class: user_color_scheme, data: { qa_selector: 'blame_file_content' } }
+ .table-responsive.file-content.blame.code{ class: "#{user_color_scheme} gl-rounded-0!", data: { qa_selector: 'blame_file_content' } }
%table
- current_line = @blame.first_line
- @blame.groups.each do |blame_group|
@@ -59,5 +59,11 @@
- current_line += line_count
- - if blame_pagination
- = paginate(blame_pagination, theme: "gitlab")
+ - if @blame_pagination && @blame_pagination.total_pages > 1
+ .gl-display-flex.gl-justify-content-center.gl-flex-direction-column.gl-align-items-center.gl-p-3.gl-bg-gray-50.gl-border-t-solid.gl-border-t-1.gl-border-gray-100
+ = _('For faster browsing, not all history is shown.')
+ = render Pajamas::ButtonComponent.new(href: namespace_project_blame_path(namespace_id: @project.namespace, project_id: @project, id: @id, no_pagination: true), size: :small, button_options: { class: 'gl-mt-3' }) do |c|
+ = _('View entire blame')
+
+ - if @blame_pagination
+ = paginate(@blame_pagination, theme: "gitlab")
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index 8260aa0fb7e..9c07713b9f8 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -6,11 +6,6 @@
.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
= 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
- = replace_blob_link(@project, @ref, @path, blob: blob)
- = delete_blob_link(@project, @ref, @path, blob: blob)
.btn-group.gl-ml-3{ role: "group" }
= copy_blob_source_button(blob) unless blame
= open_raw_blob_button(blob)
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 220319d31b5..528999f5c89 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -2,6 +2,7 @@
- page_title _("Edit"), @blob.path, @ref
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco')
+- add_page_specific_style 'page_bundles/editor'
- if @conflict
= render Pajamas::AlertComponent.new(alert_options: { class: 'gl-mb-5 gl-mt-5' },
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index 27f64104cf4..81b2715b228 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -1,5 +1,6 @@
- breadcrumb_title _("Repository")
- page_title _("New File"), @path.presence, @ref
+- add_page_specific_style 'page_bundles/editor'
%h1.page-title.blob-new-page-title.gl-font-size-h-display
= _('New file')
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index 16ecc1cc5a0..33b2229f5d1 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -3,6 +3,7 @@
- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit, limit: 1)
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco', prefetch: true)
+- add_page_startup_graphql_call('repository/blob_info', { projectPath: @project.full_path, ref: current_ref, filePath: @blob.path, shouldFetchRawText: @blob.rendered_as_text? && !@blob.rich_viewer })
.js-signature-container{ data: { 'signatures-path': signatures_path } }
diff --git a/app/views/projects/branch_rules/_show.html.haml b/app/views/projects/branch_rules/_show.html.haml
index af0e656d301..46665fdb450 100644
--- a/app/views/projects/branch_rules/_show.html.haml
+++ b/app/views/projects/branch_rules/_show.html.haml
@@ -9,4 +9,4 @@
= _('Define rules for who can push, merge, and the required approvals for each branch.')
.settings-content.gl-pr-0
- #js-branch-rules
+ #js-branch-rules{ data: { project_path: @project.full_path } }
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index f8bee5a69e9..63d0cf7145d 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -11,12 +11,12 @@
= form_tag namespace_project_branches_path, method: :post, id: "new-branch-form", class: "js-create-branch-form js-requires-input" do
.form-group.row
- = label_tag :branch_name, nil, class: 'col-form-label col-sm-2'
+ = label_tag :branch_name, _('Branch name'), class: 'col-form-label col-sm-2'
.col-sm-10
= text_field_tag :branch_name, params[:branch_name], required: true, autofocus: true, class: 'form-control js-branch-name monospace'
.form-text.text-muted.text-danger.js-branch-name-error
.form-group.row
- = label_tag :ref, 'Create from', class: 'col-form-label col-sm-2'
+ = label_tag :ref, _('Create from'), class: 'col-form-label col-sm-2'
.col-sm-10.create-from
.dropdown
= hidden_field_tag :ref, default_ref
@@ -24,7 +24,8 @@
.text-left.dropdown-toggle-text= default_ref
= sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
= render 'shared/ref_dropdown', dropdown_class: 'wide'
- .form-text.text-muted Existing branch name, tag, or commit SHA
+ .form-text.text-muted
+ = _('Existing branch name, tag, or commit SHA')
.form-actions
= render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { type: 'submit', class: 'gl-mr-3' }) do
= _('Create branch')
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index 5fd1c5cd403..10a6bc6b524 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -25,7 +25,8 @@
.input-group-append
= clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default")
= render_if_exists 'projects/buttons/geo'
- %li.divider.mt-2
+ = render_if_exists 'projects/buttons/kerberos_clone_field'
+ %li.divider.mt-2
%li.pt-2.gl-new-dropdown-item
%label.label-bold{ class: 'gl-px-4!' }
= _('Open in your IDE')
@@ -51,4 +52,3 @@
%a.dropdown-item.open-with-link{ href: xcode_uri_to_repo(@project) }
.gl-new-dropdown-item-text-wrapper
= _("Xcode")
- = render_if_exists 'projects/buttons/kerberos_clone_field'
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index bd096ed74f5..b48369322e4 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -82,7 +82,7 @@
- if stage
%td
- = job.stage
+ = job.stage_name
%td
- if job.duration
diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml
index 18eac48d42a..bc352ff6c7d 100644
--- a/app/views/projects/ci/pipeline_editor/show.html.haml
+++ b/app/views/projects/ci/pipeline_editor/show.html.haml
@@ -1,4 +1,5 @@
- @force_fluid_layout = true
+- add_page_specific_style 'page_bundles/editor'
- add_page_specific_style 'page_bundles/pipelines'
- add_page_specific_style 'page_bundles/pipeline_editor'
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 4007b657403..6b06584ea25 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -28,7 +28,7 @@
= search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control gl-form-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false }
.control.d-none.d-md-block
= link_to project_commits_path(@project, @id, rss_url_options), title: _("Commits feed"), class: 'btn gl-button btn-default btn-icon' do
- = sprite_icon('rss', css_class: 'qa-rss-icon')
+ = sprite_icon('rss')
= render_if_exists 'projects/commits/mirror_status'
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index b1fb9c70a54..eba0f336f80 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -16,7 +16,7 @@
= _('A default branch cannot be chosen for an empty project.')
- else
.form-group
- = f.label :default_branch, "Default branch", class: 'label-bold'
+ = f.label :default_branch, _("Default branch"), class: 'label-bold'
= f.select(:default_branch, @project.repository.branch_names, {}, {class: 'select2 select-wide', data: { qa_selector: 'default_branch_dropdown' }})
.form-group
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index a7dd69a9607..70df995cdf3 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -5,6 +5,8 @@
- expanded = expanded_by_default?
- reduce_visibility_form_id = 'reduce-visibility-form'
+= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
+
%section.settings.general-settings.no-animate.expanded#js-general-settings
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar')
@@ -26,23 +28,13 @@
%template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project).to_json.html_safe
.js-project-permissions-form{ data: visibility_confirm_modal_data(@project, reduce_visibility_form_id) }
-%section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } }
- .settings-header
- %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests')
- = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
- = expanded ? _('Collapse') : _('Expand')
- = render_if_exists 'projects/merge_request_settings_description_text'
-
- .settings-content
- = render_if_exists 'shared/promotions/promote_mr_features'
-
- = gitlab_ui_form_for @project, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
- %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
- = render 'projects/merge_request_settings', form: f
- = f.submit _('Save changes'), class: "btn gl-button btn-confirm rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }
-
-= render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
-
+- if show_merge_request_settings_callout?
+ %section.settings.expanded
+ = render Pajamas::AlertComponent.new(variant: :info,
+ title: _('Merge requests and approvals settings have moved.'),
+ alert_options: { class: 'js-merge-request-settings-callout gl-my-5', data: { feature_id: Users::CalloutsHelper::MERGE_REQUEST_SETTINGS_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c|
+ = c.body do
+ = _('On the left sidebar, select %{merge_requests_link} to view them.').html_safe % { merge_requests_link: link_to('Settings > Merge requests', project_settings_merge_requests_path(@project)).html_safe }
%section.settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'badges_settings_content' } }
.settings-header
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index 36347776ec9..a9913fe3d5e 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -3,7 +3,7 @@
#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),
+ project_full_path: @project.full_path,
visibility_help_path: help_page_path("user/public_access"),
project_id: @project.id,
project_name: @project.name,
diff --git a/app/views/projects/google_cloud/databases/cloudsql_form.html.haml b/app/views/projects/google_cloud/databases/cloudsql_form.html.haml
new file mode 100644
index 00000000000..05838717b49
--- /dev/null
+++ b/app/views/projects/google_cloud/databases/cloudsql_form.html.haml
@@ -0,0 +1,9 @@
+- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project)
+- add_to_breadcrumbs s_('CloudSeed|Databases'), project_google_cloud_databases_path(@project)
+- breadcrumb_title @title
+- page_title @title
+
+- @content_class = "limit-container-width" unless fluid_layout
+
+= form_tag project_google_cloud_databases_path(@project), method: 'post' do
+ #js-google-cloud-databases-cloudsql-form{ data: @js_data }
diff --git a/app/views/projects/google_cloud/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml
index 36b5630611e..4cc218ff548 100644
--- a/app/views/projects/google_cloud/gcp_regions/index.html.haml
+++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml
@@ -1,5 +1,5 @@
- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project)
-- breadcrumb_title _('CloudSeed|Regions')
+- breadcrumb_title s_('CloudSeed|Regions')
- page_title s_('CloudSeed|Regions')
- @content_class = "limit-container-width" unless fluid_layout
diff --git a/app/views/projects/harbor/repositories/index.html.haml b/app/views/projects/harbor/repositories/index.html.haml
index 0fce3b7f8aa..e6f0e3e950c 100644
--- a/app/views/projects/harbor/repositories/index.html.haml
+++ b/app/views/projects/harbor/repositories/index.html.haml
@@ -4,8 +4,9 @@
#js-harbor-registry-list-project{ data: { endpoint: project_harbor_repositories_path(@project),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
- "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
- "registry_host_url_with_port" => 'demo.harbor.com',
+ "repository_url" => @project.harbor_integration.hostname,
+ "harbor_integration_project_name" => @project.harbor_integration.project_name,
+ "project_name" => @project.name,
connection_error: (!!@connection_error).to_s,
invalid_path_error: (!!@invalid_path_error).to_s,
is_group_page: false.to_s, } }
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 16b795ee3c9..11b652cc818 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -7,4 +7,5 @@
noteable_data: serialize_issuable(@issue, with_blocking_issues: true),
noteable_type: 'Issue',
target_type: 'issue',
- current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json } }
+ current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json,
+ can_add_timeline_events: "#{can?(current_user, :admin_incident_management_timeline_event, @issue)}" } }
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 310a0c1a61e..466eca2fdb0 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -1,7 +1,7 @@
- if @related_branches.any?
%h2.gl-font-lg
= pluralize(@related_branches.size, 'Related Branch')
- %ul.related-merge-requests.gl-pl-0
+ %ul.related-merge-requests.gl-pl-0.gl-mb-3
- @related_branches.each do |branch|
%li.gl-display-flex.gl-align-items-center
- if branch[:pipeline_status].present?
diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml
index df2ffdd30ee..bc2136b89fb 100644
--- a/app/views/projects/issues/_work_item_links.html.haml
+++ b/app/views/projects/issues/_work_item_links.html.haml
@@ -1,2 +1,2 @@
- if Feature.enabled?(:work_items_hierarchy, @project)
- .js-work-item-links-root{ data: { issuable_id: @issue.id, project_path: @project.full_path, wi: work_items_index_data(@project) } }
+ .js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_path: @project.full_path, wi: work_items_index_data(@project) } }
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index f7a02c521f5..f95689c0b1d 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -3,6 +3,7 @@
- search = params[:search]
- subscribed = params[:subscribed]
- labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present?
+= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
- if labels_or_filters
#js-promote-label-modal
diff --git a/app/views/projects/merge_requests/_awards_block.html.haml b/app/views/projects/merge_requests/_awards_block.html.haml
index 80a58053ff7..64d35b4dfe6 100644
--- a/app/views/projects/merge_requests/_awards_block.html.haml
+++ b/app/views/projects/merge_requests/_awards_block.html.haml
@@ -1,5 +1,5 @@
.content-block.emoji-block.emoji-list-container.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true, api_awards_path: award_emoji_merge_request_api_path(@merge_request) do
- .ml-auto.gl-my-2
+ .gl-my-2.gl-xs-w-full
#js-vue-sort-issue-discussions
= render "projects/merge_requests/discussion_filter"
diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
index 62cd8bd94e3..22571b11639 100644
--- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
+++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
@@ -11,6 +11,10 @@
.gl-new-dropdown-inner
.gl-new-dropdown-contents
%ul
+ - if !@merge_request.merged? && current_user && moved_mr_sidebar_enabled?
+ %li.gl-new-dropdown-item.js-sidebar-subscriptions-entry-point
+ %li.gl-new-dropdown-divider
+ %hr.dropdown-divider
- if can?(current_user, :update_merge_request, @merge_request)
%li.gl-new-dropdown-item{ class: "gl-md-display-none!" }
= link_to edit_project_merge_request_path(@project, @merge_request), class: 'dropdown-item' do
@@ -32,17 +36,19 @@
.gl-new-dropdown-item-text-wrapper
= _('Reopen')
= display_issuable_type
+ - if moved_mr_sidebar_enabled?
+ %li.gl-new-dropdown-item#js-lock-entry-point
+ %li.gl-new-dropdown-item
+ %button.dropdown-item.js-copy-reference{ type: "button", data: { 'clipboard-text': @merge_request.to_reference(full: true) } }
+ .gl-new-dropdown-item-text-wrapper
+ = _('Copy reference')
- unless current_controller?('conflicts')
- - if current_user && moved_mr_sidebar_enabled?
- - if !@merge_request.merged?
+ - unless issuable_author_is_current_user(@merge_request)
+ - if moved_mr_sidebar_enabled?
%li.gl-new-dropdown-divider
%hr.dropdown-divider
- %li.gl-new-dropdown-item.js-sidebar-subscriptions-entry-point
- - unless issuable_author_is_current_user(@merge_request)
%li.gl-new-dropdown-item
= link_to new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'dropdown-item' do
.gl-new-dropdown-item-text-wrapper
= _('Report abuse')
- - if moved_mr_sidebar_enabled?
- %li.gl-new-dropdown-item#js-lock-entry-point
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 4e69dad2e12..783e3ac97c1 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -10,7 +10,7 @@
window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
window.gl.mrWidgetData.ci_troubleshooting_docs_path = '#{help_page_path('ci/troubleshooting.md')}';
window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviews/index.md', anchor: 'troubleshooting')}';
- window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}';
+ window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'require-a-successful-pipeline-for-merge')}';
window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests')}';
window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md', anchor: 'policies')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/approvals/rules.md', anchor: 'eligible-approvers')}';
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 cee8d2e92aa..17b1e5a757c 100644
--- a/app/views/projects/merge_requests/creations/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -8,7 +8,7 @@
.col-lg-6
.card-new-merge-request
%h2.gl-font-size-h2
- Source branch
+ = _('Source branch')
.clearfix
.merge-request-select.dropdown
= f.hidden_field :source_project_id
@@ -38,7 +38,7 @@
.col-lg-6
.card-new-merge-request
%h2.gl-font-size-h2
- Target branch
+ = _('Target branch')
.clearfix
- projects = target_projects(@project)
.merge-request-select.dropdown
@@ -67,5 +67,5 @@
%ul.list-unstyled.mr_target_commit
- if @merge_request.errors.any?
- = form_errors(@merge_request, pajamas_alert: true)
- = f.submit 'Compare branches and continue', class: "gl-button btn btn-confirm mr-compare-btn gl-mt-4", data: { qa_selector: "compare_branches_button" }
+ = form_errors(@merge_request)
+ = f.submit _('Compare branches and continue'), class: "gl-button btn btn-confirm mr-compare-btn gl-mt-4", data: { qa_selector: "compare_branches_button" }
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 78976be5dd7..d34848c801d 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -16,11 +16,13 @@
- add_page_startup_api_call @endpoint_metadata_url
.merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } }
+ - if moved_mr_sidebar_enabled?
+ #js-merge-sticky-header{ data: { data: sticky_header_data.to_json } }
= render "projects/merge_requests/mr_title"
.merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
= render "projects/merge_requests/mr_box"
- .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
+ .merge-request-tabs-holder{ class: "#{'js-tabs-affix' unless ENV['RAILS_ENV'] == 'test'} #{'gl-static' if moved_mr_sidebar_enabled?}" }
.merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between{ class: "#{'is-merge-request' if Feature.enabled?(:moved_mr_sidebar, @project) && !fluid_layout}" }
%ul.merge-request-tabs.nav-tabs.nav.nav-links.gl-display-flex.gl-flex-nowrap.gl-m-0.gl-p-0{ class: "#{'gl-w-full gl-lg-w-auto!' if Feature.enabled?(:moved_mr_sidebar, @project)}" }
= render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do
@@ -37,19 +39,19 @@
= tab_link_for @merge_request, :pipelines do
= _("Pipelines")
= gl_badge_tag @number_of_pipelines, { size: :sm }, { class: 'js-pipelines-mr-count' }
- = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do
+ = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab js-diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do
= tab_link_for @merge_request, :diffs do
= _("Changes")
= gl_badge_tag @diffs_count, { size: :sm }
- - if Feature.enabled?(:moved_mr_sidebar, @project)
- .gl-ml-auto.gl-align-items-center.gl-display-none.gl-md-display-flex.js-expand-sidebar{ class: "gl-lg-display-none!" }
- = render Pajamas::ButtonComponent.new(size: 'small',
- icon: 'chevron-double-lg-left',
- button_options: { class: 'js-sidebar-toggle' }) do
- = _('Expand')
.d-flex.flex-wrap.align-items-center.justify-content-lg-end
#js-vue-discussion-counter{ data: { blocks_merge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s } }
-
+ - if moved_mr_sidebar_enabled?
+ - if !!@issuable_sidebar.dig(:current_user, :id)
+ .js-issuable-todo{ data: { project_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid], id: @issuable_sidebar[:id] } }
+ .gl-ml-auto.gl-align-items-center.gl-display-none.gl-md-display-flex.gl-ml-3.js-expand-sidebar{ class: "gl-lg-display-none!" }
+ = render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left',
+ button_options: { class: 'js-sidebar-toggle' }) do
+ = _('Expand')
.tab-content#diff-notes-app
#js-diff-file-finder
#js-code-navigation
@@ -99,7 +101,7 @@
#js-review-bar
-- if Feature.enabled?(:mr_experience_survey, @project) && current_user
+- if current_user && Feature.enabled?(:mr_experience_survey, current_user)
#js-mr-experience-survey{ data: { account_age: current_user.account_age_in_days } }
= render 'projects/invite_members_modal', project: @project
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index 0d56bf7793d..c11d5e7c9b6 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -1,6 +1,6 @@
= gitlab_ui_form_for [@project, @milestone],
html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
- = form_errors(@milestone, pajamas_alert: true)
+ = form_errors(@milestone)
.form-group.row
.col-form-label.col-sm-2
= f.label :title, _('Title')
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index a90d5224d04..2ae7d300979 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -17,7 +17,7 @@
= gitlab_ui_form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f|
.panel.panel-default
.panel-body
- %div= form_errors(@project, pajamas_alert: true)
+ %div= form_errors(@project)
.form-group.has-feedback
= label_tag :url, _('Git repository URL'), class: 'label-light'
@@ -41,40 +41,4 @@
= c.body do
= _('Mirror settings are only available to GitLab administrators.')
- .panel.panel-default
- .table-responsive
- %table.table.push-pull-table
- %thead
- %tr
- %th
- = _('Mirrored repositories')
- = render_if_exists 'projects/mirrors/mirrored_repositories_count'
- %th= _('Direction')
- %th= _('Last update attempt')
- %th= _('Last successful update')
- %th
- %th
- %tbody.js-mirrors-table-body
- = render_if_exists 'projects/mirrors/table_pull_row'
- - @project.remote_mirrors.each_with_index do |mirror, index|
- - next if mirror.new_record?
- %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { qa_selector: 'mirrored_repository_row' } }
- %td{ data: { qa_selector: 'mirror_repository_url_cell' } }= mirror.safe_url || _('Invalid URL')
- %td= _('Push')
- %td
- = mirror.last_update_started_at.present? ? time_ago_with_tooltip(mirror.last_update_started_at) : _('Never')
- %td{ data: { qa_selector: 'mirror_last_update_at_cell' } }= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
- %td
- - if mirror.disabled?
- = render 'projects/mirrors/disabled_mirror_badge'
- - if mirror.last_error.present?
- = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge' }, title: html_escape(mirror.last_error.try(:strip)) }
- %td.gl-display-flex
- - if mirror_settings_enabled
- .btn-group.mirror-actions-group{ role: 'group' }
- - if mirror.ssh_key_auth?
- = clipboard_button(text: mirror.ssh_public_key, class: 'gl-button btn btn-default btn-icon', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button')
- = render 'shared/remote_mirror_update_button', remote_mirror: mirror
- = render Pajamas::ButtonComponent.new(variant: :danger,
- icon: 'remove',
- button_options: { class: 'js-delete-mirror qa-delete-mirror rspec-delete-mirror', title: _('Remove'), data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' } })
+ = render 'projects/mirrors/mirror_repos_list'
diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml
new file mode 100644
index 00000000000..2dbcbd659c8
--- /dev/null
+++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml
@@ -0,0 +1,47 @@
+- mirror_settings_enabled = can?(current_user, :admin_remote_mirror, @project)
+
+.panel.panel-default
+ .table-responsive
+ - if !@project.mirror? && @project.remote_mirrors.count == 0
+ .gl-card.gl-mt-5
+ .gl-card-header
+ %strong
+ = _('Mirrored repositories') + ' (0)'
+ .gl-card-body
+ = _('There are currently no mirrored repositories.')
+ - else
+ %table.table.push-pull-table
+ %thead
+ %tr
+ %th
+ = _('Mirrored repositories')
+ = render_if_exists 'projects/mirrors/mirrored_repositories_count'
+ %th= _('Direction')
+ %th= _('Last update attempt')
+ %th= _('Last successful update')
+ %th
+ %th
+ %tbody.js-mirrors-table-body
+ = render_if_exists 'projects/mirrors/table_pull_row'
+ - @project.remote_mirrors.each_with_index do |mirror, index|
+ - next if mirror.new_record?
+ %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { qa_selector: 'mirrored_repository_row' } }
+ %td{ data: { qa_selector: 'mirror_repository_url_cell' } }= mirror.safe_url || _('Invalid URL')
+ %td= _('Push')
+ %td
+ = mirror.last_update_started_at.present? ? time_ago_with_tooltip(mirror.last_update_started_at) : _('Never')
+ %td{ data: { qa_selector: 'mirror_last_update_at_cell' } }= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
+ %td
+ - if mirror.disabled?
+ = render 'projects/mirrors/disabled_mirror_badge'
+ - if mirror.last_error.present?
+ = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge' }, title: html_escape(mirror.last_error.try(:strip)) }
+ %td.gl-display-flex
+ - if mirror_settings_enabled
+ .btn-group.mirror-actions-group{ role: 'group' }
+ - if mirror.ssh_key_auth?
+ = clipboard_button(text: mirror.ssh_public_key, class: 'gl-button btn btn-default btn-icon', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button')
+ = render 'shared/remote_mirror_update_button', remote_mirror: mirror
+ = render Pajamas::ButtonComponent.new(variant: :danger,
+ icon: 'remove',
+ button_options: { class: 'js-delete-mirror qa-delete-mirror rspec-delete-mirror', title: _('Remove'), data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' } })
diff --git a/app/views/projects/pages/_header.html.haml b/app/views/projects/pages/_header.html.haml
index da35f2fdf09..cf51796e878 100644
--- a/app/views/projects/pages/_header.html.haml
+++ b/app/views/projects/pages/_header.html.haml
@@ -1,4 +1,4 @@
-- can_add_new_domain = can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https)
+- can_add_new_domain = can_create_pages_custom_domains?(current_user, @project)
%h1.page-title.gl-font-size-h-display.with-button
= s_('GitLabPages|Pages')
diff --git a/app/views/projects/pages/new.html.haml b/app/views/projects/pages/new.html.haml
index cdd52a933e9..5dea6b02e36 100644
--- a/app/views/projects/pages/new.html.haml
+++ b/app/views/projects/pages/new.html.haml
@@ -1,4 +1,4 @@
-- if Feature.enabled?(:use_pipeline_wizard_for_pages, @group)
+- if Feature.enabled?(:use_pipeline_wizard_for_pages, @project.group)
#js-pages{ data: @pipeline_wizard_data }
- else
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index d29030f992f..5d5ca2aaaf3 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for [@project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form pipeline-schedule-form" } do |f|
- = form_errors(@schedule, pajamas_alert: true)
+ = form_errors(@schedule)
.form-group.row
.col-md-9
= f.label :description, _('Description'), class: 'label-bold'
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index c36c3ae5adf..10dc74647b2 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -2,7 +2,7 @@
%tr.pipeline-schedule-table-row
%td
= pipeline_schedule.description
- %td.branch-name-cell
+ %td.branch-name-cell.gl-text-truncate
- if pipeline_schedule.for_tag?
= sprite_icon('tag', size: 12)
- else
@@ -17,7 +17,7 @@
%span ##{pipeline_schedule.last_pipeline.id}
- else
= s_("PipelineSchedules|None")
- %td.next-run-cell
+ %td.gl-text-gray-500{ 'data-testid': 'next-run-cell' }
- if pipeline_schedule.active? && pipeline_schedule.next_run_at
= time_ago_with_tooltip(pipeline_schedule.real_next_run)
- else
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index a8ad53db8c2..e83547fd8f8 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -42,7 +42,7 @@
#js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json),
suite_endpoint: project_pipeline_test_path(@project, @pipeline, suite_name: 'suite', format: :json),
blob_path: project_blob_path(@project, @pipeline.sha),
- has_test_report: @pipeline.has_reports?(Ci::JobArtifact.test_reports).to_s,
+ has_test_report: @pipeline.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test)).to_s,
empty_state_image_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg'),
artifacts_expired_image_path: image_path('illustrations/pipeline.svg') } }
= render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 37fe80d2aaf..34305d15eff 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -2,6 +2,7 @@
- page_title _("Members")
= render_if_exists 'projects/free_user_cap_alert', project: @project
+= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
.row.gl-mt-3
.col-lg-12
diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml
index d0fdd3a729a..9dde86f77b4 100644
--- a/app/views/projects/project_templates/_template.html.haml
+++ b/app/views/projects/project_templates/_template.html.haml
@@ -1,4 +1,4 @@
-.template-option.d-flex.align-items-center{ data: { qa_selector: 'template_option_row' } }
+.template-option.d-flex.align-items-center{ data: { qa_selector: 'template_option_container' } }
.logo.gl-mr-3.px-1
= image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}"
.description
diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml
index 20cd45be6da..34fe9a29068 100644
--- a/app/views/projects/protected_branches/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml
@@ -1,14 +1,14 @@
- content_for :merge_access_levels do
.merge_access_levels-container
= dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-merge qa-allowed-to-merge-select wide',
- dropdown_class: 'dropdown-menu-selectable qa-allowed-to-merge-dropdown rspec-allowed-to-merge-dropdown capitalize-header',
- data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }})
+ options: { toggle_class: 'js-allowed-to-merge wide',
+ dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_merge_dropdown_content', dropdown_testid: 'allowed-to-merge-dropdown',
+ data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes', qa_selector: 'allowed_to_merge_dropdown' }})
- content_for :push_access_levels do
.push_access_levels-container
= dropdown_tag('Select',
- options: { toggle_class: "js-allowed-to-push qa-allowed-to-push-select js-multiselect wide",
- dropdown_class: 'dropdown-menu-selectable qa-allowed-to-push-dropdown rspec-allowed-to-push-dropdown capitalize-header',
- data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
+ options: { toggle_class: "js-allowed-to-push js-multiselect wide",
+ dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_push_dropdown_content' , dropdown_testid: 'allowed-to-push-dropdown',
+ data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'allowed_to_push_dropdown' }})
= render 'projects/protected_branches/shared/create_protected_branch'
diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml
index 5964f2bfeda..64db51d5df2 100644
--- a/app/views/projects/protected_branches/shared/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml
@@ -1,4 +1,4 @@
-.protected-branches-list.js-protected-branches-list.qa-protected-branches-list
+.protected-branches-list.js-protected-branches-list{ data: { testid: 'protected-branches-list' } }
- if @protected_branches.empty?
.card-header.bg-white
= s_("ProtectedBranch|Protected branch (%{protected_branches_count})") % { protected_branches_count: 0 }
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 35770c32f9f..277cbf00034 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
@@ -4,7 +4,7 @@
- c.header do
= s_("ProtectedBranch|Protect a branch")
- c.body do
- = form_errors(@protected_branch, pajamas_alert: true)
+ = form_errors(@protected_branch)
.form-group.row
= f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12'
.col-sm-12
diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
index 6dd3b2e8d5e..098bd4a7eeb 100644
--- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
@@ -1,6 +1,6 @@
- can_admin_project = can?(current_user, :admin_project, @project)
-%tr.qa-protected-branch.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
+%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch), testid: 'protected-branch' } }
%td
%span.ref-name= protected_branch.name
diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
index e257117a32e..ba0935fff7d 100644
--- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -4,7 +4,7 @@
.card-header
= _('Protect a tag')
.card-body
- = form_errors(@protected_tag, pajamas_alert: true)
+ = form_errors(@protected_tag)
.form-group.row
= f.label :name, _('Tag:'), class: 'col-md-2 text-left text-md-right'
.col-md-10.protected-tags-dropdown
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index ea77bda0b0f..81526685bfc 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -16,7 +16,7 @@
.row
.col-lg-12
= gitlab_ui_form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings') do |f|
- = form_errors(@project, pajamas_alert: true)
+ = form_errors(@project)
%fieldset.builds-feature.js-auto-devops-settings
.form-group
= f.fields_for :auto_devops_attributes, @auto_devops do |form|
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 50e96528c0d..9419dacc16f 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -6,7 +6,7 @@
.row.gl-mt-3
.col-lg-12
= gitlab_ui_form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings') do |f|
- = form_errors(@project, pajamas_alert: true)
+ = form_errors(@project)
%fieldset.builds-feature
.form-group
= f.gitlab_ui_checkbox_component :public_builds,
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index dd9cc296d52..c1df7b88352 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -103,8 +103,7 @@
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
%p
- = _("Control which projects can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API.")
- = link_to _('Learn more'), help_page_path('ci/jobs/ci_job_token'), target: '_blank', rel: 'noopener noreferrer'
+ = _("Control how the CI_JOB_TOKEN CI/CD variable is used for API access between projects.")
.settings-content
= render 'ci/token_access/index'
diff --git a/app/views/projects/settings/merge_requests/show.html.haml b/app/views/projects/settings/merge_requests/show.html.haml
new file mode 100644
index 00000000000..886e276dea5
--- /dev/null
+++ b/app/views/projects/settings/merge_requests/show.html.haml
@@ -0,0 +1,18 @@
+- breadcrumb_title _('Merge requests')
+- page_title _('Merge requests')
+- @content_class = 'limit-container-width' unless fluid_layout
+
+%section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings.expanded{ class: [('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests')
+ = render_if_exists 'projects/merge_request_settings_description_text'
+
+ .settings-content
+ = render_if_exists 'shared/promotions/promote_mr_features'
+
+ = gitlab_ui_form_for @project, url: project_settings_merge_requests_path(@project), html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
+ %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
+ = render 'projects/merge_request_settings', form: f
+ = f.submit _('Save changes'), class: "btn gl-button btn-confirm rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }
+
+= render_if_exists 'projects/settings/merge_requests/merge_request_approvals_settings', expanded: true
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 87e3e03099c..90e0ccce8b4 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -2,19 +2,6 @@
- page_title _('Monitor Settings')
- breadcrumb_title _('Monitor Settings')
-= render Pajamas::AlertComponent.new(variant: :danger,
- dismissible: false,
- title: s_('Deprecations|Feature deprecation and removal')) do |c|
- = c.body do
- - removal_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/7188'
- - removal_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: removal_epic_link_url }
- - opstrace_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/6976'
- - opstrace_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: opstrace_link_url }
- - link_end = '</a>'.html_safe
- = html_escape(s_('Deprecations|The metrics feature was deprecated in GitLab 14.7.'))
- = html_escape(s_('Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0.')) % {removal_link_start: removal_epic_link_start, link_end: link_end }
- = html_escape(s_('Deprecations|For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {opstrace_link_start: opstrace_link_start, link_end: link_end }
-
= render 'projects/settings/operations/metrics_dashboard'
= render 'projects/settings/operations/error_tracking'
= render 'projects/settings/operations/alert_management'
diff --git a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml
index 795544b75a2..5244587c16d 100644
--- a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml
+++ b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs _('Packages & Registries'), project_settings_packages_and_registries_path(@project)
+- add_to_breadcrumbs _('Package and registry settings'), project_settings_packages_and_registries_path(@project)
- breadcrumb_title s_('ContainerRegistry|Clean up image tags')
-- page_title s_('ContainerRegistry|Clean up image tags'), _('Packages & Registries')
+- page_title s_('ContainerRegistry|Clean up image tags'), _('Package and registry settings')
- @content_class = 'limit-container-width' unless fluid_layout
#js-registry-settings-cleanup-image-tags{ data: cleanup_settings_data }
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 d579981ebc0..e0ac07f5f31 100644
--- a/app/views/projects/settings/packages_and_registries/show.html.haml
+++ b/app/views/projects/settings/packages_and_registries/show.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title _('Packages & Registries')
-- page_title _('Packages & Registries')
+- breadcrumb_title _('Package and registry settings')
+- page_title _('Package and registry settings')
- @content_class = 'limit-container-width' unless fluid_layout
#js-registry-settings{ data: settings_data }
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 1f529849b28..e9d1661a4f1 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -7,6 +7,7 @@
= auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
= render_if_exists 'projects/free_user_cap_alert', project: @project
+= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
= render partial: 'flash_messages', locals: { project: @project }
= render 'clusters_deprecation_alert'
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index 2721f94134c..fda797f3228 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -10,7 +10,7 @@
.nav-controls
#js-tags-sort-dropdown{ data: { filter_tags_path: filter_tags_path(search: @search, sort: @sort), sort_options: tags_sort_options_hash.to_json } }
= link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn gl-button btn-default btn-icon has-tooltip gl-ml-auto' do
- = sprite_icon('rss', css_class: 'gl-icon qa-rss-icon')
+ = sprite_icon('rss', css_class: 'gl-icon')
- if can?(current_user, :admin_tag, @project)
= link_to new_project_tag_path(@project), class: 'btn gl-button btn-confirm', data: { qa_selector: "new_tag_button" } do
= s_('TagsPage|New tag')
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 3b546888375..79fc1a64790 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -2,22 +2,21 @@
- default_ref = params[:ref] || @project.default_branch
- if @error
- = render Pajamas::AlertComponent.new(variant: :danger, dismissible: true, close_button_options: { class: 'gl-alert-dismiss' }) do |c|
+ = render Pajamas::AlertComponent.new(variant: :danger, dismissible: true ) do |c|
= c.body do
= @error
%h1.page-title.gl-font-size-h-display
= s_('TagsPage|New Tag')
-%hr
= form_tag namespace_project_tags_path, method: :post, id: "new-tag-form", class: "common-note-form tag-form js-quick-submit js-requires-input" do
.form-group.row
- = label_tag :tag_name, nil, class: 'col-form-label col-sm-2'
- .col-sm-10
+ .col-sm-12
+ = label_tag :tag_name, nil
= text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control', data: { qa_selector: "tag_name_field" }
.form-group.row
- = label_tag :ref, 'Create from', class: 'col-form-label col-sm-2'
- .col-sm-10.create-from
+ .col-sm-12.create-from
+ = label_tag :ref, 'Create from'
.dropdown
= hidden_field_tag :ref, default_ref
= button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide js-branch-select monospace', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
@@ -27,15 +26,14 @@
.form-text.text-muted
= s_('TagsPage|Existing branch name, tag, or commit SHA')
.form-group.row
- = label_tag :message, nil, class: 'col-form-label col-sm-2'
- .col-sm-10
+ .col-sm-12
+ = label_tag :message, nil
= text_area_tag :message, @message, required: false, class: 'form-control', rows: 5, data: { qa_selector: "tag_message_field" }
.form-text.text-muted
= tag_description_help_text
- %hr
.form-group.row
- = label_tag :release_description, s_('TagsPage|Release notes'), class: 'col-form-label col-sm-2'
- .col-sm-10
+ .col-sm-12
+ = label_tag :release_description, s_('TagsPage|Release notes'), class: 'gl-mb-0'
.form-text.mb-3
- link_start = '<a href="%{url}" rel="noopener noreferrer" target="_blank">'.html_safe
- releases_page_path = project_releases_path(@project)
@@ -49,7 +47,7 @@
= render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'shared/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description, qa_selector: 'release_notes_field'
= render 'shared/notes/hints'
- .form-actions.gl-display-flex
+ .gl-display-flex
= render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'gl-mr-3', data: { qa_selector: "create_tag_button" }, type: 'submit' }) do
= s_('TagsPage|Create tag')
= render Pajamas::ButtonComponent.new(href: project_tags_path(@project)) do
diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml
index d24cfd61052..9043b8e60fc 100644
--- a/app/views/projects/triggers/_form.html.haml
+++ b/app/views/projects/triggers/_form.html.haml
@@ -1,5 +1,5 @@
= form_for [@project, @trigger], html: { class: 'gl-show-field-errors' } do |f|
- = form_errors(@trigger, pajamas_alert: true)
+ = form_errors(@trigger)
- if @trigger.token
.form-group
diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml
index 3de9bce14d4..5e2217d3c9f 100644
--- a/app/views/projects/usage_quotas/index.html.haml
+++ b/app/views/projects/usage_quotas/index.html.haml
@@ -1,6 +1,6 @@
- page_title s_("UsageQuota|Usage")
-= render_if_exists 'namespaces/free_user_cap/projects/usage_quota_limitations_banner'
+= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
= render Pajamas::AlertComponent.new(title: _('Repository usage recalculation started'),
variant: :info,
diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml
index dcfab046514..ef5e3e83103 100644
--- a/app/views/search/_results_status.html.haml
+++ b/app/views/search/_results_status.html.haml
@@ -13,7 +13,7 @@
- if search_service.scope == 'blobs'
= _("in")
.mx-md-1
- = render partial: "shared/ref_switcher", locals: { ref: repository_ref(search_service.project), form_path: request.fullpath, field_name: 'repository_ref' }
+ #js-blob-ref-switcher{ data: { "project-id" => search_service.project.id, "ref" => repository_ref(search_service.project), "field-name": "repository_ref" } }
= s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
- else
= _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
diff --git a/app/views/shared/_email_with_badge.html.haml b/app/views/shared/_email_with_badge.html.haml
index 5d837657943..5013d8e439a 100644
--- a/app/views/shared/_email_with_badge.html.haml
+++ b/app/views/shared/_email_with_badge.html.haml
@@ -1,5 +1,6 @@
- variant = verified ? :success : :danger
- text = verified ? _('Verified') : _('Unverified')
-= email
-= gl_badge_tag text, { variant: variant }, { class: 'gl-ml-3' }
+%span.gl-mr-3
+ = email
+= gl_badge_tag text, { variant: variant }
diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml
index 130e73a069f..89be816fc76 100644
--- a/app/views/shared/_file_highlight.html.haml
+++ b/app/views/shared/_file_highlight.html.haml
@@ -1,6 +1,7 @@
#blob-content.file-content.code.js-syntax-highlight
- offset = defined?(first_line_number) ? first_line_number : 1
- - blame_path = project_blame_path(@project, tree_join(@ref, blob.path))
+ - if Feature.enabled?(:file_line_blame)
+ - blame_path = project_blame_path(@project, tree_join(@ref, blob.path))
.line-numbers{ class: "gl-px-0!", data: { blame_path: blame_path } }
- if blob.data.present?
- link = blob_link if defined?(blob_link)
diff --git a/app/views/shared/_integration_settings.html.haml b/app/views/shared/_integration_settings.html.haml
index d58be0f0f4a..84710b2ecc7 100644
--- a/app/views/shared/_integration_settings.html.haml
+++ b/app/views/shared/_integration_settings.html.haml
@@ -1,4 +1,4 @@
-= form_errors(integration, pajamas_alert: true)
+= form_errors(integration)
%div{ data: { testid: "integration-settings-form" } }
- if @default_integration
diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml
index a49a0667d84..7314a7ddadc 100644
--- a/app/views/shared/_md_preview.html.haml
+++ b/app/views/shared/_md_preview.html.haml
@@ -11,13 +11,13 @@
.md-header
= gl_tabs_nav({ class: 'clearfix nav-links'}) do
%li.md-header-tab.active
- %button.js-md-write-button
+ %button.js-md-write-button{ class: 'gl-py-3!' }
= _("Write")
%li.md-header-tab
- %button.js-md-preview-button
+ %button.js-md-preview-button{ class: 'gl-py-3!' }
= _("Preview")
- %li.md-header-toolbar.active
+ %li.md-header-toolbar.active.gl-py-2
= render 'shared/blob/markdown_buttons', show_fullscreen_button: true
.md-write-holder
diff --git a/app/views/shared/access_tokens/_created_container.html.haml b/app/views/shared/access_tokens/_created_container.html.haml
index c5a18d98b89..c0aaa46e761 100644
--- a/app/views/shared/access_tokens/_created_container.html.haml
+++ b/app/views/shared/access_tokens/_created_container.html.haml
@@ -3,7 +3,7 @@
= _('Your new %{type}') % { type: type }
.form-group
.input-group
- = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: 'qa-created-access-token form-control js-select-on-focus', 'aria-describedby' => 'created-token-help-block'
+ = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: 'form-control js-select-on-focus', data: { qa_selector: 'created_access_token_field' }, 'aria-describedby' => 'created-token-help-block'
%span.input-group-append
= clipboard_button(text: new_token_value, title: _('Copy %{type}') % { type: type }, placement: 'left', class: 'input-group-text btn-default btn-clipboard')
%span#created-token-help-block.form-text.text-muted.text-danger
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index dd4d2ab46c1..0c88ac66b8b 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -13,7 +13,7 @@
= gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { id: 'js-new-access-token-form', class: 'js-requires-input' }, remote: ajax do |f|
- = form_errors(token, pajamas_alert: true)
+ = form_errors(token)
.row
.form-group.col
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index c070baf02b1..c3835386d5a 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -1,7 +1,7 @@
- board = local_assigns.fetch(:board, nil)
- @no_breadcrumb_container = true
- @no_container = true
-- @content_wrapper_class = "#{@content_wrapper_class} gl-relative"
+- @content_wrapper_class = "#{@content_wrapper_class} gl-relative gl-pb-0"
- @content_class = "issue-boards-content js-focus-mode-board"
- is_epic_board = board.to_type == "EpicBoard"
- if is_epic_board
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index 38985319ca5..93f31629ca7 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -2,7 +2,7 @@
- deploy_key = local_assigns.fetch(:deploy_key)
- deploy_keys_project = deploy_key.deploy_keys_project_for(@project)
-= form_errors(deploy_key, pajamas_alert: true)
+= form_errors(deploy_key)
.form-group
= form.label :title, class: 'col-form-label col-sm-2'
diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml
index 4bedce71c0f..d76ef8feb62 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for [@project.namespace, @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input container" } do |f|
- = form_errors(@deploy_keys.new_key, pajamas_alert: true)
+ = form_errors(@deploy_keys.new_key)
.form-group.row
= f.label :title, class: "label-bold"
= f.text_field :title, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'deploy_key_title_field' }
diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml
index aa4a3deaac4..79bf35e2726 100644
--- a/app/views/shared/deploy_tokens/_index.html.haml
+++ b/app/views/shared/deploy_tokens/_index.html.haml
@@ -1,4 +1,4 @@
-- expanded = expand_deploy_tokens_section?(@new_deploy_token)
+- expanded = expand_deploy_tokens_section?(@new_deploy_token, @created_deploy_token)
%section.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_tokens_settings_content' } }
.settings-header
@@ -8,11 +8,10 @@
%p
= description
.settings-content
- - if @new_deploy_token.persisted?
- = render 'shared/deploy_tokens/new_deploy_token', deploy_token: @new_deploy_token
+ - if @created_deploy_token
+ = render 'shared/deploy_tokens/new_deploy_token', deploy_token: @created_deploy_token
%h5.gl-mt-0
= s_('DeployTokens|New deploy token')
= render 'shared/deploy_tokens/form', group_or_project: group_or_project, token: @new_deploy_token, presenter: @deploy_tokens
%hr
= render 'shared/deploy_tokens/table', group_or_project: group_or_project, active_tokens: @deploy_tokens
-
diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml
index 9810754f52b..b40e2630011 100644
--- a/app/views/shared/doorkeeper/applications/_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form' } do |f|
- = form_errors(@application, pajamas_alert: true)
+ = form_errors(@application)
.form-group
= f.label :name, class: 'label-bold'
diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml
index f533b5b5a4d..562b1aee4ca 100644
--- a/app/views/shared/doorkeeper/applications/_show.html.haml
+++ b/app/views/shared/doorkeeper/applications/_show.html.haml
@@ -15,7 +15,14 @@
%td
= _('Secret')
%td
- = clipboard_button(clipboard_text: @application.secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button")
+ - if Feature.enabled?('hash_oauth_secrets')
+ - if @application.plaintext_secret
+ = clipboard_button(clipboard_text: @application.plaintext_secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button")
+ %span= _('This is the only time the secret is accessible. Copy the secret and store it securely.')
+ - else
+ = _('The secret is only available when you first create the application.')
+ - else
+ = clipboard_button(clipboard_text: @application.secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button")
%tr
%td
= _('Callback URL')
diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml
index 164773f9b60..f8304d5e44e 100644
--- a/app/views/shared/groups/_group.html.haml
+++ b/app/views/shared/groups/_group.html.haml
@@ -1,7 +1,7 @@
- user = local_assigns.fetch(:user, current_user)
- access = user&.max_member_access_for_group(group.id)
-%li.group-row.py-3.gl-align-items-center{ class: "gl-display-flex!#{' no-description' if group.description.blank?}" }
+%li.group-row.py-3.gl-align-items-center{ class: "gl-display-flex!" }
.avatar-container.rect-avatar.s40.gl-flex-shrink-0
= link_to group do
= group_icon(group, class: "avatar s40")
diff --git a/app/views/shared/groups/_visibility_level.html.haml b/app/views/shared/groups/_visibility_level.html.haml
deleted file mode 100644
index 1a13de9b76a..00000000000
--- a/app/views/shared/groups/_visibility_level.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-= f.label :visibility_level, class: 'label-bold' do
- = _('Visibility level')
-.js-visibility-level-dropdown{ data: { visibility_level_options: visibility_level_options(@group).to_json, default_level: f.object.visibility_level } }
diff --git a/app/views/shared/hook_logs/_recent_deliveries_table.html.haml b/app/views/shared/hook_logs/_recent_deliveries_table.html.haml
index 3c91c2f6ab4..500eb29fa93 100644
--- a/app/views/shared/hook_logs/_recent_deliveries_table.html.haml
+++ b/app/views/shared/hook_logs/_recent_deliveries_table.html.haml
@@ -23,7 +23,7 @@
- if hook_logs.present?
- = paginate hook_logs, theme: 'gitlab'
+ = paginate_without_count hook_logs
- else
.gl-text-center.gl-mt-7
%h4= _('No webhook events')
diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml
index f0e4b915ac8..69ff477d415 100644
--- a/app/views/shared/issuable/_feed_buttons.html.haml
+++ b/app/views/shared/issuable/_feed_buttons.html.haml
@@ -1,7 +1,7 @@
- show_calendar_button = local_assigns.fetch(:show_calendar_button, true)
= link_to safe_params.merge(rss_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body', testid: 'rss-feed-link' }, title: _('Subscribe to RSS feed') , 'aria-label': _('Subscribe to RSS feed') do
- = sprite_icon('rss', css_class: 'qa-rss-icon')
+ = sprite_icon('rss')
- if show_calendar_button
= link_to safe_params.merge(calendar_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar'), 'aria-label': _('Subscribe to calendar') do
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index ae8b266c092..53eb6f4c63b 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -3,7 +3,7 @@
- project = @target_project || @project
- presenter = local_assigns.fetch(:presenter, nil)
-= form_errors(issuable, pajamas_alert: true)
+= form_errors(issuable)
- if @conflict
= render Pajamas::AlertComponent.new(variant: :danger,
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 6da094924a0..f2ce0676a9a 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -20,13 +20,7 @@
.js-issuable-todo{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } }
= form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
- - if signed_in && moved_sidebar_enabled
- .block.to-do
- .title.hide-collapsed.gl-font-weight-bold.gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mt-2{ class: 'gl-mb-0!' }
- = _('To-Do')
- .js-issuable-todo{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } }
-
- .block.assignee{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { qa_selector: 'assignee_block_container' } }
+ .block.assignee.qa-assignee-block{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { qa_selector: 'assignee_block_container' } }
= render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in
- if issuable_sidebar[:supports_severity]
@@ -88,7 +82,8 @@
.js-sidebar-participants-entry-point
.block.with-sub-blocks
- #js-reference-entry-point
+ - if !moved_sidebar_enabled
+ #js-reference-entry-point
- if issuable_type == 'merge_request' && !moved_sidebar_enabled
.sub-block.js-sidebar-source-branch
.sidebar-collapsed-icon.js-dont-change-state
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 2fd4c598580..e9b04579808 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -6,7 +6,7 @@
max_assignees: dropdown_options[:data][:"max-select"],
directly_invite_members: can_admin_project_member?(@project) } }
.title.hide-collapsed
- = _('Assignee')
+ = s_('Label|Assignee')
= gl_loading_icon(inline: true)
.js-sidebar-assignee-data.selectbox.hide-collapsed
diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml
index cd976b88304..3f78f29ea24 100644
--- a/app/views/shared/issuable/_sidebar_reviewers.html.haml
+++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml
@@ -2,7 +2,7 @@
#js-vue-sidebar-reviewers{ data: { field: issuable_type, signed_in: signed_in } }
.title.hide-collapsed
- = _('Reviewer')
+ = _('Reviewers')
= gl_loading_icon(inline: true)
.selectbox.hide-collapsed
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index 8ab002f755f..634e927f891 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -31,10 +31,14 @@
- if issuable.merged?
%code= target_title
- unless issuable.new_record? || issuable.merged?
- %span.dropdown.gl-ml-2.d-inline-block
- = form.hidden_field(:target_branch,
- { class: 'target_branch js-target-branch-select ref-name mw-xl',
- data: { placeholder: _('Select branch'), endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }})
+ .merge-request-select.dropdown.gl-w-auto
+ = form.hidden_field :target_branch
+ = dropdown_toggle form.object.target_branch.presence || _("Select branch"), { toggle: "dropdown", 'field-name': "#{form.object_name}[target_branch]", 'refs-url': refs_project_path(@project, sort: 'updated_desc', find: 'branches'), selected: form.object.target_branch, default_text: _("Select branch") }, { toggle_class: "js-compare-dropdown js-target-branch monospace" }
+ .dropdown-menu.dropdown-menu-selectable.js-target-branch-dropdown.target_branch.ref-name.git-revision-dropdown
+ = dropdown_title(_("Select branch"))
+ = dropdown_filter(_("Search branches"))
+ = dropdown_content
+ = dropdown_loading
- if source_level < target_level
= render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c|
diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml
index 5831460d59a..09086d3aa82 100644
--- a/app/views/shared/issuable/form/_merge_params.html.haml
+++ b/app/views/shared/issuable/form/_merge_params.html.haml
@@ -11,9 +11,9 @@
- if issuable.can_remove_source_branch?(current_user)
.form-check.gl-mb-3
= hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil
- = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input'
+ = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input js-form-update'
= label_tag 'merge_request[force_remove_source_branch]', class: 'form-check-label' do
- Delete source branch when merge request is accepted.
+ = _("Delete source branch when merge request is accepted.")
- if !project.squash_never?
.form-check
- if project.squash_always?
@@ -21,9 +21,9 @@
= check_box_tag 'merge_request[squash]', '1', project.squash_enabled_by_default?, class: 'form-check-input', disabled: 'true'
- else
= hidden_field_tag 'merge_request[squash]', '0', id: nil
- = check_box_tag 'merge_request[squash]', '1', issuable_squash_option?(issuable, project), class: 'form-check-input'
+ = check_box_tag 'merge_request[squash]', '1', issuable_squash_option?(issuable, project), class: 'form-check-input js-form-update'
= label_tag 'merge_request[squash]', class: 'form-check-label' do
- Squash commits when merge request is accepted.
+ = _("Squash commits when merge request is accepted.")
= link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer'
- if project.squash_always?
.gl-text-gray-400
diff --git a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
index efecffbcc2e..bd9afc3ce69 100644
--- a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
@@ -1,4 +1,4 @@
-= form.label :assignee_id, issuable.allows_multiple_assignees? ? _('Assignees') : _('Assignee'), class: "col-12"
+= form.label :assignee_id, issuable.allows_multiple_assignees? ? _('Assignees') : s_('SearchToken|Assignee'), class: "col-12"
.col-12
.issuable-form-select-holder.selectbox
- issuable.assignees.each do |assignee|
diff --git a/app/views/shared/issue_type/_emoji_block.html.haml b/app/views/shared/issue_type/_emoji_block.html.haml
index d2c851a4e49..61e28f18d3b 100644
--- a/app/views/shared/issue_type/_emoji_block.html.haml
+++ b/app/views/shared/issue_type/_emoji_block.html.haml
@@ -4,8 +4,7 @@
.row.gl-m-0.gl-justify-content-space-between
.js-noteable-awards
= render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path
- .new-branch-col.gl-my-2.gl-font-size-0
+ .new-branch-col.gl-display-flex.gl-my-2.gl-font-size-0.gl-gap-3
= render_if_exists "projects/issues/timeline_toggle", issuable: issuable
- #js-vue-sort-issue-discussions
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(issuable), notes_filters: UserPreference.notes_filters.to_json } }
= render 'new_branch' if show_new_branch_button?
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index cf8bd23b153..e6d6d0998dc 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -1,5 +1,5 @@
= form_for @label, as: :label, url: url, html: { class: 'label-form js-quick-submit js-requires-input' } do |f|
- = form_errors(@label, pajamas_alert: true)
+ = form_errors(@label)
.form-group.row
.col-12
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 59d1537aa2b..01548325c83 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -43,7 +43,8 @@
- if milestone.merge_requests_enabled?
&middot;
= link_to pluralize(milestone.total_merge_requests_count, _('Merge request')), merge_requests_path
- .float-lg-right.light #{milestone.percent_complete}% complete
+ .float-lg-right.light
+ = format(s_('Milestone|%{percentage}%{percent} complete'), percentage: milestone.percent_complete, percent: '%')
.col-md-2
.milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end
- if @project # if in milestones list on project level
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index 44740db5a00..fb000b9aab1 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -9,7 +9,7 @@
- else
= html_escape(s_('MarkdownToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}')) % { markdownDocsLinkStart: markdownLinkStart, markdownDocsLinkEnd: '</a>'.html_safe }
- if supports_file_upload
- %span.uploading-container
+ %span.uploading-container.gl-line-height-32
%span.uploading-progress-container.hide
= sprite_icon('paperclip', css_class: 'gl-icon gl-vertical-align-text-bottom')
%span.attaching-file-message
diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml
index 51a5c9dd38f..a5170b199e8 100644
--- a/app/views/shared/projects/_search_form.html.haml
+++ b/app/views/shared/projects/_search_form.html.haml
@@ -1,5 +1,5 @@
- form_field_classes = local_assigns[:admin_view] || !Feature.enabled?(:project_list_filter_bar) ? 'input-short js-projects-list-filter' : ''
-- placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : 'Filter by name...'
+- placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : _('Filter by name')
= form_tag filter_projects_path, method: :get, class: 'project-filter-form', data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f|
= search_field_tag :name, params[:name],
diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml
index e0079a95cec..024b06fe97a 100644
--- a/app/views/shared/runners/_form.html.haml
+++ b/app/views/shared/runners/_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for runner, url: runner_form_url do |f|
- = form_errors(runner, pajamas_alert: true)
+ = form_errors(runner)
.form-group.row
= label :active, _("Active"), class: 'col-form-label col-sm-2'
.col-sm-10
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index fe68244f1da..afe72767b9a 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -1,4 +1,4 @@
-= form_errors(hook, pajamas_alert: true)
+= form_errors(hook)
.form-group
= form.label :url, s_('Webhooks|URL'), class: 'label-bold'
diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml
index 5d07b0f95ab..5ec82ad6702 100644
--- a/app/views/shared/web_hooks/_index.html.haml
+++ b/app/views/shared/web_hooks/_index.html.haml
@@ -1,5 +1,5 @@
%hr
-.card
+.card#webhooks-index
.card-header
%h5
= hook_class.underscore.humanize.titleize.pluralize
diff --git a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
new file mode 100644
index 00000000000..d9155b397b8
--- /dev/null
+++ b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
@@ -0,0 +1,13 @@
+- return unless show_project_hook_failed_callout?(project: @project)
+
+- content_for :after_flash_content do
+ = render Pajamas::AlertComponent.new(variant: :danger,
+ title: s_('Webhooks|Webhook disabled'),
+ alert_options: { class: 'gl-my-4 js-web-hook-disabled-callout',
+ data: { feature_id: Users::CalloutsHelper::WEB_HOOK_DISABLED, dismiss_endpoint: project_callouts_path, project_id: @project.id, defer_links: 'true'} }) do |c|
+ = c.body do
+ = s_('Webhooks|A webhook in this project was automatically disabled after being retried multiple times.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks'), target: '_blank', rel: 'noopener noreferrer'
+ = c.actions do
+ = link_to s_('Webhooks|Go to webhooks'), project_hooks_path(@project, anchor: 'webhooks-index'), class: 'btn gl-alert-action btn-confirm gl-button'
diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml
index 0d5e59919cb..34bedbd928a 100644
--- a/app/views/shared/wikis/_form.html.haml
+++ b/app/views/shared/wikis/_form.html.haml
@@ -1,6 +1,6 @@
- page_info = { last_commit_sha: @page.last_commit_sha, persisted: @page.persisted?, title: @page.title, content: @page.content || '', format: @page.format.to_s, uploads_path: uploads_path, path: wiki_page_path(@wiki, @page), wiki_path: wiki_path(@wiki), help_path: help_page_path('user/project/wiki/index'), markdown_help_path: help_page_path('user/markdown'), markdown_preview_path: wiki_page_path(@wiki, @page, action: :preview_markdown), create_path: wiki_path(@wiki, action: :create) }
.gl-mt-3
- = form_errors(@page, truncate: :title, pajamas_alert: true)
+ = form_errors(@page, truncate: :title)
#js-wiki-form{ data: { page_info: page_info.to_json, format_options: wiki_markup_hash_by_name_id.to_json } }
diff --git a/app/views/shared/wikis/_wiki_content.html.haml b/app/views/shared/wikis/_wiki_content.html.haml
index 42e8037bb0f..780e4c4746d 100644
--- a/app/views/shared/wikis/_wiki_content.html.haml
+++ b/app/views/shared/wikis/_wiki_content.html.haml
@@ -1,2 +1,2 @@
-.js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json } }
+.js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json } }
= render_wiki_content(@page)
diff --git a/app/views/shared/wikis/git_error.html.haml b/app/views/shared/wikis/git_error.html.haml
index dab3b940b9a..12eddb4a61e 100644
--- a/app/views/shared/wikis/git_error.html.haml
+++ b/app/views/shared/wikis/git_error.html.haml
@@ -9,6 +9,6 @@
.gl-mt-5.gl-mb-3
.gl-display-flex.gl-justify-content-space-between
%h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page ? @page.human_title : _('Failed to retrieve page')
- .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content' } }
+ .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content' } }
= _('The page could not be displayed because it timed out.')
= html_escape(_('You can view the source or %{linkStart}%{cloneIcon} clone the repository%{linkEnd}')) % { linkStart: "<a href=\"#{git_access_url}\">".html_safe, linkEnd: '</a>'.html_safe, cloneIcon: sprite_icon('download', css_class: 'gl-mr-2').html_safe }
diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml
index 6591e8fae7b..3841113231c 100644
--- a/app/views/shared/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -27,6 +27,6 @@
- if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding
= link_to sprite_icon('pencil', css_class: 'gl-icon'), wiki_page_path(@wiki, @page, action: :edit), title: 'Edit', role: "button", class: 'btn gl-button btn-icon btn-default js-wiki-edit', data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' }
- .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } }
+ .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } }
= render 'shared/wikis/sidebar'
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 25070138128..952023b3745 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -36,7 +36,7 @@
- if can?(current_user, :read_user_profile, @user)
= link_to user_path(@user, rss_url_options), class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip',
title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
- = sprite_icon('rss', css_class: 'qa-rss-icon')
+ = sprite_icon('rss')
- if current_user && current_user.admin?
= link_to [:admin, @user], class: link_classes + 'btn gl-button btn-default btn-icon', title: s_('UserProfile|View user in admin area'),
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 8bba5e36b52..9b282340d0a 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -786,6 +786,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: cronjob:users_migrate_records_to_ghost_user_in_batches
+ :worker_name: Users::MigrateRecordsToGhostUserInBatchesWorker
+ :feature_category: :users
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:x509_issuer_crl_check
:worker_name: X509IssuerCrlCheckWorker
:feature_category: :source_code_management
@@ -1056,6 +1065,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_import_protected_branch
+ :worker_name: Gitlab::GithubImport::ImportProtectedBranchWorker
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_import_pull_request
:worker_name: Gitlab::GithubImport::ImportPullRequestWorker
:feature_category: :importers
@@ -1083,6 +1101,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_import_release_attachments
+ :worker_name: Gitlab::GithubImport::ImportReleaseAttachmentsWorker
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_refresh_import_jid
:worker_name: Gitlab::GithubImport::RefreshImportJidWorker
:feature_category: :importers
@@ -1101,6 +1128,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_stage_import_attachments
+ :worker_name: Gitlab::GithubImport::Stage::ImportAttachmentsWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_stage_import_base_data
:worker_name: Gitlab::GithubImport::Stage::ImportBaseDataWorker
:feature_category: :importers
@@ -1146,6 +1182,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_stage_import_protected_branches
+ :worker_name: Gitlab::GithubImport::Stage::ImportProtectedBranchesWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_stage_import_pull_requests
:worker_name: Gitlab::GithubImport::Stage::ImportPullRequestsWorker
:feature_category: :importers
@@ -1578,6 +1623,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: pipeline_background:ci_job_artifacts_track_artifact_report
+ :worker_name: Ci::JobArtifacts::TrackArtifactReportWorker
+ :feature_category: :code_testing
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: pipeline_background:ci_pending_builds_update_group
:worker_name: Ci::PendingBuilds::UpdateGroupWorker
:feature_category: :continuous_integration
@@ -2379,6 +2433,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: google_cloud_fetch_google_ip_list
+ :worker_name: GoogleCloud::FetchGoogleIpListWorker
+ :feature_category: :build_artifacts
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: group_destroy
:worker_name: GroupDestroyWorker
:feature_category: :subgroups
@@ -2415,6 +2478,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: groups_update_two_factor_requirement_for_members
+ :worker_name: Groups::UpdateTwoFactorRequirementForMembersWorker
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: import_issues_csv
:worker_name: ImportIssuesCsvWorker
:feature_category: :team_planning
@@ -2496,6 +2568,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: issues_close
+ :worker_name: Issues::CloseWorker
+ :feature_category: :source_code_management
+ :has_external_dependencies: false
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 2
+ :idempotent: true
+ :tags: []
- :name: issues_placement
:worker_name: Issues::PlacementWorker
:feature_category: :team_planning
diff --git a/app/workers/analytics/usage_trends/counter_job_worker.rb b/app/workers/analytics/usage_trends/counter_job_worker.rb
index b3a8f7dd3c2..e6de623f784 100644
--- a/app/workers/analytics/usage_trends/counter_job_worker.rb
+++ b/app/workers/analytics/usage_trends/counter_job_worker.rb
@@ -3,6 +3,8 @@
module Analytics
module UsageTrends
class CounterJobWorker
+ TIMEOUT = 250.seconds
+
extend ::Gitlab::Utils::Override
include ApplicationWorker
@@ -15,24 +17,27 @@ module Analytics
idempotent!
- def perform(measurement_identifier, min_id, max_id, recorded_at)
+ def perform(measurement_identifier, min_id, max_id, recorded_at, partial_results = nil)
query_scope = ::Analytics::UsageTrends::Measurement.identifier_query_mapping[measurement_identifier].call
- count = if min_id.nil? || max_id.nil? # table is empty
- 0
- else
- counter(query_scope, min_id, max_id)
- end
+ result = counter(query_scope, min_id, max_id, partial_results)
+
+ # If the batch counter timed out, schedule a job to continue counting later
+ if result[:status] == :timeout
+ return self.class.perform_async(measurement_identifier, result[:continue_from], max_id, recorded_at, result[:partial_results])
+ end
- return if count == Gitlab::Database::BatchCounter::FALLBACK
+ return if result[:status] != :completed
- UsageTrends::Measurement.insert_all([{ recorded_at: recorded_at, count: count, identifier: measurement_identifier }])
+ UsageTrends::Measurement.insert_all([{ recorded_at: recorded_at, count: result[:count], identifier: measurement_identifier }])
end
private
- def counter(query_scope, min_id, max_id)
- Gitlab::Database::BatchCount.batch_count(query_scope, start: min_id, finish: max_id)
+ def counter(query_scope, min_id, max_id, partial_results)
+ return { status: :completed, count: 0 } if min_id.nil? || max_id.nil? # table is empty
+
+ Gitlab::Database::BatchCount.batch_count_with_timeout(query_scope, start: min_id, finish: max_id, timeout: TIMEOUT, partial_results: partial_results)
end
end
end
diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb
index 36a50735fed..7503ea3d800 100644
--- a/app/workers/ci/build_finished_worker.rb
+++ b/app/workers/ci/build_finished_worker.rb
@@ -36,10 +36,10 @@ module Ci
build.update_coverage
Ci::BuildReportResultService.new.execute(build)
- build.feature_flagged_execute_hooks
+ build.execute_hooks
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
build.track_deployment_usage
- build.track_verify_usage
+ build.track_verify_environment_usage
if build.failed? && !build.auto_retry_expected?
::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id)
diff --git a/app/workers/ci/job_artifacts/track_artifact_report_worker.rb b/app/workers/ci/job_artifacts/track_artifact_report_worker.rb
new file mode 100644
index 00000000000..3df8c284ab3
--- /dev/null
+++ b/app/workers/ci/job_artifacts/track_artifact_report_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobArtifacts
+ class TrackArtifactReportWorker
+ include ApplicationWorker
+
+ data_consistency :delayed
+
+ include PipelineBackgroundQueue
+
+ feature_category :code_testing
+
+ idempotent!
+
+ def perform(pipeline_id)
+ Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+ Ci::JobArtifacts::TrackArtifactReportService.new.execute(pipeline)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb
index 127eb3b6f44..53bed0fa9da 100644
--- a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb
+++ b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb
@@ -20,7 +20,7 @@ module Ci
return unless pipeline
pipeline.root_ancestor.try do |root_ancestor_pipeline|
- next unless root_ancestor_pipeline.self_and_descendants_complete?
+ next unless root_ancestor_pipeline.self_and_project_descendants_complete?
Ci::PipelineArtifacts::CoverageReportService.new(root_ancestor_pipeline).execute
end
diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb
index 73501315575..3a506470743 100644
--- a/app/workers/cleanup_container_repository_worker.rb
+++ b/app/workers/cleanup_container_repository_worker.rb
@@ -24,7 +24,7 @@ class CleanupContainerRepositoryWorker
return unless valid?
Projects::ContainerRepository::CleanupTagsService
- .new(container_repository, current_user, params)
+ .new(container_repository: container_repository, current_user: current_user, params: params)
.execute
end
diff --git a/app/workers/flush_counter_increments_worker.rb b/app/workers/flush_counter_increments_worker.rb
index e21a7ee35e7..8c7e17587d0 100644
--- a/app/workers/flush_counter_increments_worker.rb
+++ b/app/workers/flush_counter_increments_worker.rb
@@ -11,6 +11,7 @@ class FlushCounterIncrementsWorker
data_consistency :always
sidekiq_options retry: 3
+ loggable_arguments 0, 2
# The increments in `ProjectStatistics` are owned by several teams depending
# on the counter
diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb
index 70d18d8004c..fdf4ec6f396 100644
--- a/app/workers/gitlab/github_import/advance_stage_worker.rb
+++ b/app/workers/gitlab/github_import/advance_stage_worker.rb
@@ -25,6 +25,8 @@ module Gitlab
issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker,
issue_events: Stage::ImportIssueEventsWorker,
notes: Stage::ImportNotesWorker,
+ attachments: Stage::ImportAttachmentsWorker,
+ protected_branches: Stage::ImportProtectedBranchesWorker,
lfs_objects: Stage::ImportLfsObjectsWorker,
finish: Stage::FinishImportWorker
}.freeze
diff --git a/app/workers/gitlab/github_import/import_protected_branch_worker.rb b/app/workers/gitlab/github_import/import_protected_branch_worker.rb
new file mode 100644
index 00000000000..c083d8ee867
--- /dev/null
+++ b/app/workers/gitlab/github_import/import_protected_branch_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ImportProtectedBranchWorker # rubocop:disable Scalability/IdempotentWorker
+ include ObjectImporter
+
+ worker_resource_boundary :cpu
+
+ def representation_class
+ Gitlab::GithubImport::Representation::ProtectedBranch
+ end
+
+ def importer_class
+ Importer::ProtectedBranchImporter
+ end
+
+ def object_type
+ :protected_branch
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/import_release_attachments_worker.rb b/app/workers/gitlab/github_import/import_release_attachments_worker.rb
new file mode 100644
index 00000000000..c6f45ec1d7c
--- /dev/null
+++ b/app/workers/gitlab/github_import/import_release_attachments_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ImportReleaseAttachmentsWorker # rubocop:disable Scalability/IdempotentWorker
+ include ObjectImporter
+
+ def representation_class
+ Representation::ReleaseAttachments
+ end
+
+ def importer_class
+ Importer::ReleaseAttachmentsImporter
+ end
+
+ def object_type
+ :release_attachment
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb
new file mode 100644
index 00000000000..e9086dca503
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportAttachmentsWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ sidekiq_options retry: 5
+ include GithubImport::Queue
+ include StageMethods
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ return skip_to_next_stage(project) if feature_disabled?(project)
+
+ waiters = importers.each_with_object({}) do |importer, hash|
+ waiter = start_importer(project, importer, client)
+ hash[waiter.key] = waiter.jobs_remaining
+ end
+ move_to_next_stage(project, waiters)
+ end
+
+ private
+
+ # For future issue/mr/milestone/etc attachments importers
+ def importers
+ [::Gitlab::GithubImport::Importer::ReleasesAttachmentsImporter]
+ end
+
+ def start_importer(project, importer, client)
+ info(project.id, message: "starting importer", importer: importer.name)
+ importer.new(project, client).execute
+ end
+
+ def skip_to_next_stage(project)
+ info(project.id, message: "skipping importer", importer: 'Attachments')
+ move_to_next_stage(project)
+ end
+
+ def move_to_next_stage(project, waiters = {})
+ AdvanceStageWorker.perform_async(
+ project.id,
+ waiters,
+ :protected_branches
+ )
+ end
+
+ def feature_disabled?(project)
+ Feature.disabled?(:github_importer_attachments_import, project.group, type: :ops)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb
index 167b3e147a3..b53e31ce40e 100644
--- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb
@@ -21,11 +21,7 @@ module Gitlab
hash[waiter.key] = waiter.jobs_remaining
end
- AdvanceStageWorker.perform_async(
- project.id,
- waiters,
- :lfs_objects
- )
+ AdvanceStageWorker.perform_async(project.id, waiters, :attachments)
end
def importers(project)
diff --git a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb
new file mode 100644
index 00000000000..6d6dea10e64
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportProtectedBranchesWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ sidekiq_options retry: 3
+ include GithubImport::Queue
+ include StageMethods
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ info(project.id,
+ message: "starting importer",
+ importer: 'Importer::ProtectedBranchesImporter')
+ waiter = Importer::ProtectedBranchesImporter
+ .new(project, client)
+ .execute
+
+ project.import_state.refresh_jid_expiration
+
+ AdvanceStageWorker.perform_async(
+ project.id,
+ { waiter.key => waiter.jobs_remaining },
+ :lfs_objects
+ )
+ rescue StandardError => e
+ Gitlab::Import::ImportFailureService.track(
+ project_id: project.id,
+ error_source: self.class.name,
+ exception: e,
+ metrics: true
+ )
+
+ raise(e)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/jira_import/import_issue_worker.rb b/app/workers/gitlab/jira_import/import_issue_worker.rb
index 3824cc1f3ef..eabe988dfc2 100644
--- a/app/workers/gitlab/jira_import/import_issue_worker.rb
+++ b/app/workers/gitlab/jira_import/import_issue_worker.rb
@@ -15,8 +15,7 @@ module Gitlab
loggable_arguments 3
def perform(project_id, jira_issue_id, issue_attributes, waiter_key)
- issue_id = create_issue(issue_attributes, project_id)
- JiraImport.cache_issue_mapping(issue_id, jira_issue_id, project_id)
+ create_issue(issue_attributes, project_id)
rescue StandardError => ex
# Todo: Record jira issue id(or better jira issue key),
# so that we can report the list of failed to import issues to the user
diff --git a/app/workers/gitlab_service_ping_worker.rb b/app/workers/gitlab_service_ping_worker.rb
index a974667e5e0..b02e7318585 100644
--- a/app/workers/gitlab_service_ping_worker.rb
+++ b/app/workers/gitlab_service_ping_worker.rb
@@ -15,17 +15,24 @@ class GitlabServicePingWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3, dead: false
sidekiq_retry_in { |count| (count + 1) * 8.hours.to_i }
- def perform
- # Disable service ping for GitLab.com
+ def perform(options = {})
+ # Sidekiq does not support keyword arguments, so the args need to be
+ # passed the old pre-Ruby 2.0 way.
+ #
+ # See https://github.com/mperham/sidekiq/issues/2372
+ triggered_from_cron = options.fetch('triggered_from_cron', true)
+ skip_db_write = options.fetch('skip_db_write', false)
+
+ # Disable service ping for GitLab.com unless called manually
# See https://gitlab.com/gitlab-org/gitlab/-/issues/292929 for details
- return if Gitlab.com?
+ return if Gitlab.com? && triggered_from_cron
# Multiple Sidekiq workers could run this. We should only do this at most once a day.
in_lock(LEASE_KEY, ttl: LEASE_TIMEOUT) do
# Splay the request over a minute to avoid thundering herd problems.
sleep(rand(0.0..60.0).round(3))
- ServicePing::SubmitService.new(payload: usage_data).execute
+ ServicePing::SubmitService.new(payload: usage_data, skip_db_write: skip_db_write).execute
end
end
diff --git a/app/workers/google_cloud/create_cloudsql_instance_worker.rb b/app/workers/google_cloud/create_cloudsql_instance_worker.rb
index 3c15c59b8d9..8c4f4c83339 100644
--- a/app/workers/google_cloud/create_cloudsql_instance_worker.rb
+++ b/app/workers/google_cloud/create_cloudsql_instance_worker.rb
@@ -8,30 +8,15 @@ module GoogleCloud
feature_category :not_owned # rubocop:disable Gitlab/AvoidFeatureCategoryNotOwned
idempotent!
- def perform(user_id, project_id, options = {})
+ def perform(user_id, project_id, params = {})
user = User.find(user_id)
project = Project.find(project_id)
+ params = params.with_indifferent_access
- google_oauth2_token = options[:google_oauth2_token]
- gcp_project_id = options[:gcp_project_id]
- instance_name = options[:instance_name]
- database_version = options[:database_version]
- environment_name = options[:environment_name]
- is_protected = options[:is_protected]
-
- params = {
- google_oauth2_token: google_oauth2_token,
- gcp_project_id: gcp_project_id,
- instance_name: instance_name,
- database_version: database_version,
- environment_name: environment_name,
- is_protected: is_protected
- }
-
- response = GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute
+ response = ::GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute
if response[:status] == :error
- raise response[:message]
+ raise "Error SetupCloudsqlInstanceService: #{response.to_json}"
end
end
end
diff --git a/app/workers/google_cloud/fetch_google_ip_list_worker.rb b/app/workers/google_cloud/fetch_google_ip_list_worker.rb
new file mode 100644
index 00000000000..b14b4e735dc
--- /dev/null
+++ b/app/workers/google_cloud/fetch_google_ip_list_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+ class FetchGoogleIpListWorker
+ include ApplicationWorker
+
+ data_consistency :delayed
+ feature_category :build_artifacts
+ urgency :low
+ deduplicate :until_executing
+ idempotent!
+
+ def perform
+ GoogleCloud::FetchGoogleIpListService.new.execute
+ end
+ end
+end
diff --git a/app/workers/groups/update_two_factor_requirement_for_members_worker.rb b/app/workers/groups/update_two_factor_requirement_for_members_worker.rb
new file mode 100644
index 00000000000..ac1d3589516
--- /dev/null
+++ b/app/workers/groups/update_two_factor_requirement_for_members_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# Worker for updating two factor requirement for all group members
+module Groups
+ class UpdateTwoFactorRequirementForMembersWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ idempotent!
+
+ feature_category :authentication_and_authorization
+
+ def perform(group_id)
+ group = Group.find_by_id(group_id)
+
+ return unless group
+
+ group.update_two_factor_requirement_for_members
+ end
+ end
+end
diff --git a/app/workers/issues/close_worker.rb b/app/workers/issues/close_worker.rb
new file mode 100644
index 00000000000..0d540ee8c4f
--- /dev/null
+++ b/app/workers/issues/close_worker.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Issues
+ class CloseWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ sidekiq_options retry: 3
+
+ idempotent!
+ deduplicate :until_executed, including_scheduled: true
+ feature_category :source_code_management
+ urgency :high
+ weight 2
+
+ def perform(project_id, issue_id, issue_type, params = {})
+ project = Project.find_by_id(project_id)
+
+ unless project
+ logger.info(structured_payload(message: "Project not found.", project_id: project_id))
+ return
+ end
+
+ issue = case issue_type
+ when "ExternalIssue"
+ ExternalIssue.new(issue_id, project)
+ else
+ Issue.find_by_id(issue_id)
+ end
+
+ unless issue
+ logger.info(structured_payload(message: "Issue not found.", issue_id: issue_id))
+ return
+ end
+
+ author = User.find_by_id(params["closed_by"])
+
+ unless author
+ logger.info(structured_payload(message: "User not found.", user_id: params["closed_by"]))
+ return
+ end
+
+ commit = Commit.build_from_sidekiq_hash(project, params["commit_hash"])
+ service = Issues::CloseService.new(project: project, current_user: author)
+
+ service.execute(issue, commit: commit)
+ end
+ end
+end
diff --git a/app/workers/namespaces/onboarding_issue_created_worker.rb b/app/workers/namespaces/onboarding_issue_created_worker.rb
index aab5767e0f1..4f0cc71cd91 100644
--- a/app/workers/namespaces/onboarding_issue_created_worker.rb
+++ b/app/workers/namespaces/onboarding_issue_created_worker.rb
@@ -18,7 +18,7 @@ module Namespaces
namespace = Namespace.find_by_id(namespace_id)
return unless namespace
- OnboardingProgressService.new(namespace).execute(action: :issue_created)
+ Onboarding::ProgressService.new(namespace).execute(action: :issue_created)
end
end
end
diff --git a/app/workers/namespaces/onboarding_pipeline_created_worker.rb b/app/workers/namespaces/onboarding_pipeline_created_worker.rb
index 4172e286474..c3850880df0 100644
--- a/app/workers/namespaces/onboarding_pipeline_created_worker.rb
+++ b/app/workers/namespaces/onboarding_pipeline_created_worker.rb
@@ -18,7 +18,7 @@ module Namespaces
namespace = Namespace.find_by_id(namespace_id)
return unless namespace
- OnboardingProgressService.new(namespace).execute(action: :pipeline_created)
+ Onboarding::ProgressService.new(namespace).execute(action: :pipeline_created)
end
end
end
diff --git a/app/workers/namespaces/onboarding_progress_worker.rb b/app/workers/namespaces/onboarding_progress_worker.rb
index 77a31d85a9a..49629428459 100644
--- a/app/workers/namespaces/onboarding_progress_worker.rb
+++ b/app/workers/namespaces/onboarding_progress_worker.rb
@@ -19,7 +19,7 @@ module Namespaces
namespace = Namespace.find_by_id(namespace_id)
return unless namespace && action
- OnboardingProgressService.new(namespace).execute(action: action.to_sym)
+ Onboarding::ProgressService.new(namespace).execute(action: action.to_sym)
end
end
end
diff --git a/app/workers/namespaces/onboarding_user_added_worker.rb b/app/workers/namespaces/onboarding_user_added_worker.rb
index 4d17cf9a6e2..a1b349eedd3 100644
--- a/app/workers/namespaces/onboarding_user_added_worker.rb
+++ b/app/workers/namespaces/onboarding_user_added_worker.rb
@@ -15,7 +15,7 @@ module Namespaces
def perform(namespace_id)
namespace = Namespace.find(namespace_id)
- OnboardingProgressService.new(namespace).execute(action: :user_added)
+ Onboarding::ProgressService.new(namespace).execute(action: :user_added)
end
end
end
diff --git a/app/workers/namespaces/process_sync_events_worker.rb b/app/workers/namespaces/process_sync_events_worker.rb
index 2bf2a4a6ef8..d0124c69781 100644
--- a/app/workers/namespaces/process_sync_events_worker.rb
+++ b/app/workers/namespaces/process_sync_events_worker.rb
@@ -13,7 +13,7 @@ module Namespaces
urgency :high
idempotent!
- deduplicate :until_executing
+ deduplicate :until_executed, if_deduplicated: :reschedule_once
def perform
results = ::Ci::ProcessSyncEventsService.new(
diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb
index b7d938e6b68..3e681c3f111 100644
--- a/app/workers/object_storage/migrate_uploads_worker.rb
+++ b/app/workers/object_storage/migrate_uploads_worker.rb
@@ -11,7 +11,7 @@ module ObjectStorage
include ObjectStorageQueue
feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
- loggable_arguments 0, 1, 2, 3
+ loggable_arguments 0
SanityCheckError = Class.new(StandardError)
@@ -67,41 +67,19 @@ module ObjectStorage
include Report
# rubocop: disable CodeReuse/ActiveRecord
- def self.enqueue!(uploads, model_class, mounted_as, to_store)
- sanity_check!(uploads, model_class, mounted_as)
-
- perform_async(uploads.ids, model_class.to_s, mounted_as, to_store)
+ def self.enqueue!(uploads, to_store)
+ perform_async(uploads.ids, to_store)
end
# rubocop: enable CodeReuse/ActiveRecord
- # We need to be sure all the uploads are for the same uploader and model type
- # and that the mount point exists if provided.
- #
- def self.sanity_check!(uploads, model_class, mounted_as)
- upload = uploads.first
- uploader_class = upload.uploader.constantize
- uploader_types = uploads.map(&:uploader).uniq
- model_types = uploads.map(&:model_type).uniq
- model_has_mount = mounted_as.nil? || model_class.uploaders[mounted_as] == uploader_class
-
- raise(SanityCheckError, _("Multiple uploaders found: %{uploader_types}") % { uploader_types: uploader_types }) unless uploader_types.count == 1
- raise(SanityCheckError, _("Multiple model types found: %{model_types}") % { model_types: model_types }) unless model_types.count == 1
- raise(SanityCheckError, _("Mount point %{mounted_as} not found in %{model_class}.") % { mounted_as: mounted_as, model_class: model_class }) unless model_has_mount
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def perform(*args)
- args_check!(args)
-
- (ids, model_type, mounted_as, to_store) = args
+ ids, to_store = retrieve_applicable_args!(args)
- @model_class = model_type.constantize
- @mounted_as = mounted_as&.to_sym
@to_store = to_store
uploads = Upload.preload(:model).where(id: ids)
- sanity_check!(uploads)
results = migrate(uploads)
report!(results)
@@ -111,31 +89,22 @@ module ObjectStorage
end
# rubocop: enable CodeReuse/ActiveRecord
- def sanity_check!(uploads)
- self.class.sanity_check!(uploads, @model_class, @mounted_as)
- end
-
- def args_check!(args)
- return if args.count == 4
+ private
- case args.count
- when 3 then raise SanityCheckError, _("Job is missing the `model_type` argument.")
- else
- raise SanityCheckError, _("Job has wrong arguments format.")
- end
- end
+ def retrieve_applicable_args!(args)
+ return args if args.count == 2
+ return args.values_at(0, 3) if args.count == 4
- def build_uploaders(uploads)
- uploads.map { |upload| upload.retrieve_uploader(@mounted_as) }
+ raise SanityCheckError, _("Job has wrong arguments format.")
end
def migrate(uploads)
- build_uploaders(uploads).map(&method(:process_uploader))
+ uploads.map(&method(:process_upload))
end
- def process_uploader(uploader)
- MigrationResult.new(uploader.upload).tap do |result|
- uploader.migrate!(@to_store)
+ def process_upload(upload)
+ MigrationResult.new(upload).tap do |result|
+ upload.retrieve_uploader.migrate!(@to_store)
rescue StandardError => e
result.error = e
end
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index a4dfe11c394..cd6ce6eb28b 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -34,7 +34,7 @@ class ProcessCommitWorker
return unless user
- commit = build_commit(project, commit_hash)
+ commit = Commit.build_from_sidekiq_hash(project, commit_hash)
author = commit.author || user
process_commit_message(project, commit, user, author, default)
@@ -51,12 +51,22 @@ class ProcessCommitWorker
end
def close_issues(project, user, author, commit, issues)
- # We don't want to run permission related queries for every single issue,
- # therefore we use IssueCollection here and skip the authorization check in
- # Issues::CloseService#execute.
- IssueCollection.new(issues).updatable_by_user(user).each do |issue|
- Issues::CloseService.new(project: project, current_user: author)
- .close_issue(issue, closed_via: commit)
+ if Feature.enabled?(:process_issue_closure_in_background, project)
+ Issues::CloseWorker.bulk_perform_async_with_contexts(
+ issues,
+ arguments_proc: -> (issue) {
+ [project.id, issue.id, issue.class.to_s, { closed_by: author.id, commit_hash: commit.to_hash }]
+ },
+ context_proc: -> (issue) { { project: project } }
+ )
+ else
+ # We don't want to run permission related queries for every single issue,
+ # therefore we use IssueCollection here and skip the authorization check in
+ # Issues::CloseService#execute.
+ IssueCollection.new(issues).updatable_by_user(user).each do |issue|
+ Issues::CloseService.new(project: project, current_user: author)
+ .close_issue(issue, closed_via: commit)
+ end
end
end
@@ -75,19 +85,4 @@ class ProcessCommitWorker
.with_first_mention_not_earlier_than(commit.committed_date)
.update_all(first_mentioned_in_commit_at: commit.committed_date)
end
-
- def build_commit(project, hash)
- date_suffix = '_date'
-
- # When processing Sidekiq payloads various timestamps are stored as Strings.
- # Commit in turn expects Time-like instances upon input, so we have to
- # manually parse these values.
- hash.each do |key, value|
- if key.to_s.end_with?(date_suffix) && value.is_a?(String)
- hash[key] = Time.zone.parse(value)
- end
- end
-
- Commit.from_hash(hash, project)
- end
end
diff --git a/app/workers/projects/inactive_projects_deletion_cron_worker.rb b/app/workers/projects/inactive_projects_deletion_cron_worker.rb
index a280c9203d6..ba6d44ec4a5 100644
--- a/app/workers/projects/inactive_projects_deletion_cron_worker.rb
+++ b/app/workers/projects/inactive_projects_deletion_cron_worker.rb
@@ -39,8 +39,6 @@ module Projects
raise TimeoutError
end
- next unless Feature.enabled?(:inactive_projects_deletion, project.root_namespace)
-
with_context(project: project, user: admin_user) do
deletion_warning_email_sent_on = notified_inactive_projects["project:#{project.id}"]
diff --git a/app/workers/projects/process_sync_events_worker.rb b/app/workers/projects/process_sync_events_worker.rb
index 57f3e3dee5e..4bbe1b65e5a 100644
--- a/app/workers/projects/process_sync_events_worker.rb
+++ b/app/workers/projects/process_sync_events_worker.rb
@@ -13,7 +13,7 @@ module Projects
urgency :high
idempotent!
- deduplicate :until_executing
+ deduplicate :until_executed, if_deduplicated: :reschedule_once
def perform
results = ::Ci::ProcessSyncEventsService.new(
diff --git a/app/workers/ssh_keys/expired_notification_worker.rb b/app/workers/ssh_keys/expired_notification_worker.rb
index dc1efce51ce..768579214c6 100644
--- a/app/workers/ssh_keys/expired_notification_worker.rb
+++ b/app/workers/ssh_keys/expired_notification_worker.rb
@@ -15,19 +15,20 @@ module SshKeys
# rubocop: disable CodeReuse/ActiveRecord
def perform
- order = Gitlab::Pagination::Keyset::Order.build([
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'expires_at_utc',
- order_expression: Arel.sql("date(expires_at AT TIME ZONE 'UTC')").asc,
- nullable: :not_nullable,
- distinct: false,
- add_to_projections: true
- ),
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'id',
- order_expression: Key.arel_table[:id].asc
- )
- ])
+ order = Gitlab::Pagination::Keyset::Order.build(
+ [
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'expires_at_utc',
+ order_expression: Arel.sql("date(expires_at AT TIME ZONE 'UTC')").asc,
+ nullable: :not_nullable,
+ distinct: false,
+ add_to_projections: true
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: Key.arel_table[:id].asc
+ )
+ ])
scope = Key.expired_today_and_not_notified.order(order)
diff --git a/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb b/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb
new file mode 100644
index 00000000000..ddddfc106ae
--- /dev/null
+++ b/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Users
+ class MigrateRecordsToGhostUserInBatchesWorker
+ include ApplicationWorker
+ include Gitlab::ExclusiveLeaseHelpers
+ include CronjobQueue # rubocop: disable Scalability/CronWorkerContext
+
+ sidekiq_options retry: false
+ feature_category :users
+ data_consistency :always
+ idempotent!
+
+ def perform
+ return unless Feature.enabled?(:user_destroy_with_limited_execution_time_worker)
+
+ in_lock(self.class.name.underscore, ttl: Gitlab::Utils::ExecutionTracker::MAX_RUNTIME, retries: 0) do
+ Users::MigrateRecordsToGhostUserInBatchesService.new.execute
+ end
+ end
+ end
+end